TechEarl

Build Make / Model / Year URLs in WordPress (Catalog and Directory Sites)

How to build three-level /make/model/year/ URLs in WordPress with add_rewrite_rule, custom query vars, and a vehicle CPT, plus the static-prefix collision that breaks the naive rule. Works the same on a catalog of brand/series/model or category/sub/sku.

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
How to build /make/model/year/ URLs in WordPress: an add_rewrite_rule that captures three path segments into custom query vars, a vehicle custom post type with make/model/year taxonomies, and the static-prefix fix for the rule collision with core post and page rewrites.

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):

php
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).

php
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:

php
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:

php
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/:

php
// 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, with te_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:

  1. 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 with vehicles/, 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.
  2. 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.
  3. 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:

DomainLevel 1Level 2Level 3Example URL
Vehiclesmakemodelyear/vehicles/toyota/camry/2024/
Electronicsbrandseriesmodel/products/sony/bravia/xr-a95l/
Partscategorysubcategorysku/parts/brakes/rotors/br-44821/
Propertiescityneighborhoodlisting/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:
php
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:

bash
# 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 -n1

To see the parsed query vars rather than just the status, dump them temporarily:

php
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

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPRewrite RulesCustom Post TypesPermalinksDirectory Sites

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts