To serve a three-level URL like /toyota/camry/2024/ in WordPress, you register a rewrite rule that captures the three path segments into custom query variables, whitelist those vars, then read them in pre_get_posts to load the matching records. Here is the rule, with a static vehicles prefix that keeps it from fighting WordPress core (the why is the whole second half of this article):
add_action( 'init', function () {
add_rewrite_rule(
'^vehicles/([^/]+)/([^/]+)/([^/]+)/?$',
'index.php?te_make=$matches[1]&te_model=$matches[2]&te_year=$matches[3]',
'top'
);
} );
add_filter( 'query_vars', function ( $vars ) {
$vars[] = 'te_make';
$vars[] = 'te_model';
$vars[] = 'te_year';
return $vars;
} );After adding this you must flush rewrite rules once (visit Settings, Permalinks, or call flush_rewrite_rules() from an activation hook). Then a request to /vehicles/toyota/camry/2024/ lands on your front controller with te_make, te_model, and te_year populated, and you query against them.
The signature, straight from the reference, is add_rewrite_rule( string $regex, string|array $query, string $after = 'bottom' ). The $after argument is the priority knob: 'top' puts your rule ahead of WordPress's own rules, 'bottom' (the default) puts it behind them. That one argument is the difference between a working directory and a 404, and I will come back to it.
The data model: CPT plus taxonomies, or three loose query vars
Before the routing, decide what make, model, and year actually are in your data. There are two sane shapes.
Option A: a vehicle custom post type with three taxonomies. Each listing is a post; make, model, and year are taxonomy terms attached to it. This is the right call when listings are real content with their own bodies, images, and metadata (a dealer inventory, a parts catalog, a product directory).
add_action( 'init', function () {
register_post_type( 'vehicle', array(
'public' => true,
'has_archive' => true,
'supports' => array( 'title', 'editor', 'thumbnail', 'custom-fields' ),
'rewrite' => array( 'slug' => 'vehicles' ),
) );
foreach ( array( 'make', 'model', 'year' ) as $taxonomy ) {
register_taxonomy( "vehicle_{$taxonomy}", 'vehicle', array(
'hierarchical' => false,
'public' => true,
'rewrite' => false,
) );
}
} );I set 'rewrite' => false on the taxonomies deliberately: I do not want WordPress generating its own /vehicle_make/toyota/ URLs that compete with the clean path I am building by hand. The taxonomies exist for filtering and term relationships; the URL is owned entirely by my rewrite rule.
Option B: three free-form query vars, no taxonomy. If make/model/year are just facets you match against post meta (or against an external table), you can skip taxonomies entirely. The rule above already captures them as te_make / te_model / te_year; you read those in pre_get_posts and translate them into a meta_query or a custom SQL lookup. Lighter, but you lose term archives and the admin term UI.
Use the CPT-plus-taxonomy model when the catalog is editorial and someone manages it in wp-admin. Use loose query vars when the data lives elsewhere and WordPress is just the router and the renderer.
For Option A, the lookup in pre_get_posts reads the captured vars and constrains the main query by the matching terms:
add_action( 'pre_get_posts', function ( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
$make = $query->get( 'te_make' );
$model = $query->get( 'te_model' );
$year = $query->get( 'te_year' );
if ( ! $make ) {
return; // not one of our routes
}
$query->set( 'post_type', 'vehicle' );
$tax_query = array( 'relation' => 'AND' );
$tax_query[] = te_term_clause( 'vehicle_make', $make );
if ( $model ) {
$tax_query[] = te_term_clause( 'vehicle_model', $model );
}
if ( $year ) {
$tax_query[] = te_term_clause( 'vehicle_year', $year );
}
$query->set( 'tax_query', $tax_query );
} );
function te_term_clause( $taxonomy, $slug ) {
return array(
'taxonomy' => $taxonomy,
'field' => 'slug',
'terms' => sanitize_title( $slug ),
);
}Note te_term_clause() carries the te_ prefix because it is a helper I define; $make, $model, and $year stay as plain variable names. The query var keys are te_make and friends so they are unlikely to collide with anything WordPress or another plugin registers.
Partial paths: /toyota/ and /toyota/camry/ archives
A directory is more useful when the parent segments are landing pages too. /vehicles/toyota/ should list every Toyota; /vehicles/toyota/camry/ should list every Camry across years. You get this by registering the shorter rules alongside the three-segment one, most specific first:
add_action( 'init', function () {
// /vehicles/toyota/camry/2024/ - a single make+model+year listing
add_rewrite_rule(
'^vehicles/([^/]+)/([^/]+)/([^/]+)/?$',
'index.php?te_make=$matches[1]&te_model=$matches[2]&te_year=$matches[3]',
'top'
);
// /vehicles/toyota/camry/ - every year of one model
add_rewrite_rule(
'^vehicles/([^/]+)/([^/]+)/?$',
'index.php?te_make=$matches[1]&te_model=$matches[2]',
'top'
);
// /vehicles/toyota/ - every model of one make
add_rewrite_rule(
'^vehicles/([^/]+)/?$',
'index.php?te_make=$matches[1]',
'top'
);
} );Order matters because the regexes overlap: /vehicles/toyota/camry/ matches both the two-segment and (if you let it) a greedier pattern. Registering the most specific rule first, all at 'top', means WordPress tests them in that order and the right one wins. The pre_get_posts callback already degrades correctly: it only sets te_model / te_year if those vars are present, so the same code renders the single listing, the model archive, and the make archive.
The gotcha that wastes an afternoon: a rule with no static prefix
Here is the mistake I made the first time, and that I have since watched several other people make. It is tempting to drop the vehicles/ prefix and serve the catalog from the site root, so the URL is the cleaner /toyota/camry/2024/:
// Looks tidy. Quietly breaks the rest of your site.
add_rewrite_rule(
'^([^/]+)/([^/]+)/([^/]+)/?$',
'index.php?te_make=$matches[1]&te_model=$matches[2]&te_year=$matches[3]',
'top'
);That regex has no static leading segment. ^([^/]+)/... matches any first path component, so at 'top' priority it sits in front of WordPress's own rules and swallows requests it has no business touching. The damage is broad:
- A normal page at
/about/team/history/now resolves to your vehicle controller, withte_make=about, and 404s or shows an empty listing. - Date-based and hierarchical permalinks (
/2024/08/some-post/, nested pages, category trees three levels deep) get intercepted before core ever sees them. - The wp-admin and login paths are usually safe (different rule set), but the entire front end of a content site becomes a minefield.
The rule is not "wrong" in isolation. The problem is that it is too broad and too high-priority, so it shadows the page, post, category, and date rules WordPress generates from your permalink structure. You have three ways out, in order of how much I trust them:
- Use a static prefix (
^vehicles/...). This is what every snippet above does, and it is the boring correct answer. A literal first segment means the rule only ever matches URLs that begin withvehicles/, so there is no overlap with anything else on the site. The URL is slightly longer; that is a fair trade for a router that does not eat your pages. - Register at
'bottom', not'top'. Default priority puts your rule after WordPress's, so a real page or post is matched first and only genuinely unclaimed three-segment paths fall through to you. This works, but it is fragile: it depends on every legitimate URL being claimed by an earlier rule, and the moment you have a vehicle path that also looks like a valid page path, the page wins and your listing disappears. Prefix beats priority. - Disambiguate inside the controller. Keep the broad rule but, in
pre_get_posts, verify the first segment is actually a known make before hijacking the query; if it is not, bail and let WordPress continue. This is the most code and the easiest to get subtly wrong, and it still does not stop the broad rule from being tested against every URL. I reach for it only when a product requirement genuinely forbids a prefix.
If you remember one thing from this article: a rewrite rule whose regex can match the first path segment of the whole site needs either a static prefix or a very good reason. The prefix is almost always the right call.
Generalizing beyond cars
Nothing here is automotive. The three-segment shape is just a hierarchy, and the same rule structure serves any catalog where listings nest three levels deep:
| Domain | Level 1 | Level 2 | Level 3 | Example URL |
|---|---|---|---|---|
| Vehicles | make | model | year | /vehicles/toyota/camry/2024/ |
| Electronics | brand | series | model | /products/sony/bravia/xr-a95l/ |
| Parts | category | subcategory | sku | /parts/brakes/rotors/br-44821/ |
| Properties | city | neighborhood | listing | /homes/austin/mueller/1423-zach/ |
Swap the static prefix and the three query var names, keep the rule mechanics identical. The taxonomy choice tracks the domain (a product CPT with brand / series / model taxonomies, a part CPT with category / subcategory and a SKU stored as meta). The location directory pattern (/state/city/) is the same technique at two levels instead of three; I cover that one separately in the state and city location-URL build.
Flush the rules (once)
New or changed rewrite rules do not take effect until WordPress regenerates its cached rule set. Two ways:
- Manual: visit Settings, Permalinks in wp-admin and click Save. That alone re-flushes; you do not have to change anything.
- Programmatic, on plugin activation only:
register_activation_hook( __FILE__, function () {
// The rule-registering callback must already have run.
te_register_vehicle_routes();
flush_rewrite_rules();
} );Do not call flush_rewrite_rules() on every init. It is an expensive write to the options table and running it on each request is a real performance problem on a busy site. Register the rules on init, flush only on activation (or once by hand).
Verify with curl
Once flushed, confirm each level routes before you touch templates. A correctly routed listing returns 200; a broken prefix collision usually shows up as a 404 or as the wrong template:
# single listing
curl -sI https://example.com/vehicles/toyota/camry/2024/ | head -n1
# model archive
curl -sI https://example.com/vehicles/toyota/camry/ | head -n1
# make archive
curl -sI https://example.com/vehicles/toyota/ | head -n1To see the parsed query vars rather than just the status, dump them temporarily:
add_action( 'template_redirect', function () {
if ( isset( $_GET['te_debug'] ) ) {
global $wp_query;
wp_die( '<pre>' . esc_html( print_r( $wp_query->query_vars, true ) ) . '</pre>' );
}
} );Hit /vehicles/toyota/camry/2024/?te_debug=1 and you should see te_make, te_model, and te_year set to the path segments. If they are empty, the rule did not match (check the flush, check the regex) or another rule claimed the URL first (the prefix or priority problem above). Remove the debug block before shipping.
See also
- How add_rewrite_rule Works in WordPress: the foundation under this article, with the full regex-to-query-var mechanics, the flush lifecycle, and why priority and order decide which rule wins
- Build State and City Location URLs in WordPress: the same routing pattern at two levels for a
/state/city/directory, including the city-versus-page disambiguation problem - How to Optimize WooCommerce: if your catalog grows into a store, where the real performance levers sit once you have the URLs right
Sources
Authoritative references this article was fact-checked against.
- add_rewrite_rule(): WordPress Developer Referencedeveloper.wordpress.org
- register_post_type(): WordPress Developer Referencedeveloper.wordpress.org
- query_vars filter: WordPress Developer Referencedeveloper.wordpress.org





