I have a site which has pages that show products (these aren't sold on the site, though, it's just informational).

Some categories have subcategories, so the URI for a subcategory archive page looks like this:


But some categories don't have any subcategories, so the URI for a product post in that category looks like this:


So either the first URL works and the 2nd one is 404, or the second URL works and the first one is 404, as either a subcategory name or a post slug can be in that third position in the URL.

I suspect that the WP's built-in process which resolves permalinks to an internal URL which has the relevant information in query vars, seems to be unable to tell whether the URL points to a subcategory or a product post in a single-level category.

I've tried all sorts of settings using rewrite_rules_array but I can only ever get one or the other working.

Here's what I have in the rewrite_rules_array that works for posts but not subcategories, for example:

$new_rules['products/([^/]+)/([^/]+)/?$'] = 'index.php?product_category=$matches[1]&product=$matches[2]';

And here's a rule which works for a subcategory archive page, but fails on a single-level-category post:

$new_rules['products/([^/]+)/([^/]+)/?$'] = 'index.php?product_category=$matches[2]';

(Though this is also the default behaviour if I don't add anything.)

Note that the array key is the same for both URLs, as they both have the same URL format. I'm sure I'm missing something simple here!

  • you mentioned the WP URL rewriter, what is this referring to? This is very ambiguous and could mean multiple things from a plugin to permalink issues to redirections to the wrong place. Can you state the problem in more concrete terms without referring to theories about the potential cause? E.g. I have URL A that should lead to product B but it instead goes to category C that has the same slug. Try to avoid implying things and state them explicitly even if it seems patronising
    – Tom J Nowell
    Commented Jan 25 at 13:53
  • 1
    Thanks, I'll update the question
    – JoLoCo
    Commented Jan 25 at 14:01

2 Answers 2


Your challenge lies in the way WordPress interprets URL patterns and resolves them to query variables. When you have a URL structure where the third segment can be either a subcategory or a product name, WordPress struggles to differentiate between the two. This is a common issue in WordPress when using custom post types and taxonomies with similar permalink structures.

To resolve this, you need a strategy that helps WordPress distinguish between a subcategory and a product post. Here's a suggestion:

Modify the Rewrite Rules: You need to create rewrite rules that are specific enough to differentiate between a subcategory and a product. One way to do this is to add a prefix or a specific identifier in the URL for products.

Check for Existence of Subcategory: Another approach is to add a function in your functions.php file that checks if the given term exists as a subcategory. If it does, it redirects to the subcategory; otherwise, it assumes it's a product.

Here's an example of how you might implement this:

function wpb_custom_rewrite_rule() {
    add_rewrite_rule('^products/([^/]+)/([^/]+)/?$', 'index.php?wpb_path=$matches[1]&wpb_second_path=$matches[2]', 'top');
add_action('init', 'wpb_custom_rewrite_rule', 10, 0);

function wpb_custom_query_vars($query_vars) {
    $query_vars[] = 'wpb_path';
    $query_vars[] = 'wpb_second_path';
    return $query_vars;
add_filter('query_vars', 'wpb_custom_query_vars');

function wpb_template_redirect_intercept() {
    global $wp_query;
    if (isset($wp_query->query_vars['wpb_path']) && isset($wp_query->query_vars['wpb_second_path'])) {
        $path = $wp_query->query_vars['wpb_path'];
        $second_path = $wp_query->query_vars['wpb_second_path'];
        // Check if $second_path is a subcategory
        if (term_exists($second_path, 'product_category')) {
            // It's a subcategory
            $wp_query->set('product_category', $second_path);
        } else {
            // Assume it's a product
            $wp_query->set('product', $second_path);
add_action('template_redirect', 'wpb_template_redirect_intercept');

In this code:

We add a new rewrite rule that captures two path segments after /products/. We add these segments to the query vars. In the template_redirect hook, we check if the second segment is a subcategory. If it is, we set the query to that subcategory. If not, we assume it's a product. Remember to flush your rewrite rules after adding this code (you can do this by visiting the Permalinks settings page in WordPress and clicking 'Save Changes').

This solution assumes that product_category and product are the correct query vars for your setup. Adjust them as needed based on your custom taxonomy and post type. Also, ensure your taxonomy and post type are set up to handle these custom query vars. ( make sure to flush your permalinks after changes like this )

  • Thankyou for the thorough reply! I'll try this now, and will let you know how it goes.
    – JoLoCo
    Commented Jan 25 at 16:54
  • 1
    Thank you again for this answer! I didn't use it exactly as-is in the end, but it did lead me to a working solution, which I'll post as a separate solution.
    – JoLoCo
    Commented Jan 29 at 22:06

The other answer (from 'WordPress Buddha') almost worked, in fact I suspect on a plain WP install it would work fine, but on my site it appeared that some other plugins were running too early to detect the change to the main WP Query, so some SEO and other stuff was showing the wrong information.

However, the answer did confirm to me that to keep this URL structure, we'd need to write some PHP code that would act differently depending on whether the slug in the 3rd 'part' of the URL was a product or a subcategory.

So after much experimentation, I ended up with this working code:

function wpse422073_handle_products_url_structure_clash () {

    // We don't do anything here unless we have a URL which might be
    // either a subcategory archive page, or a product in a top-level category,
    // i.e. /products/top-category/slug-can-be-subcategory-or-product/
    if ( str_starts_with( $_SERVER[ 'REQUEST_URI' ], '/products/' ) && mb_strlen( $_SERVER[ 'REQUEST_URI' ] ) > 10 ) {

        // Get the parts of the URL into an array
        // (with the slashes being the split points)
        $uri_parts = explode( '/', trim( parse_url( $_SERVER[ 'REQUEST_URI' ], PHP_URL_PATH ), '/' ) );

        // Again, we won't do anything if we don't have
        // the right number of slashes with stuff between them
        if ( count( $uri_parts ) === 3 ) {

            // Check to see if the last slug in the URL is a product category
            $category = term_exists( $uri_parts[ 2 ], 'product_category' );

            if ( empty( $category ) ) {

                // If the slug exists as a product category,
                // then we'll treat this request as so
                add_rewrite_rule( '^products/([^/]+)/([^/]+)/?$', 'index.php?product_category=$matches[1]&product=$matches[2]', 'top' );

            else {

                // If the slug isn't a product category, then it's a product page
                add_rewrite_rule( '^products/([^/]+)/([^/]+)/?$', 'index.php?product_category=$matches[2]', 'top' );


            // We have to call flush_rewrite_rules() to apply the changes,
            // but we pass false to tell it to not update .htaccess (a "soft flush")
            // although I've not actually seen it update .htaccess anyway,
            // so perhaps there's some other thing we're missing there!
            flush_rewrite_rules( false );



add_action( 'init', 'wpse422073_handle_products_url_structure_clash', 10, 0 );

So because this function is running on the init hook, this does run on every non-cached page load, but we quickly do some string checking so the function exits fast on URLs that aren't connected to this issue. (I did string checking, but of course a regex could be used. The actual site has some more complications, not added here, which is why I went with string checking.)

If the URL matches the right format, we break the URL into sections (at the slashes) and check to see if the third element is a product category. If it is, then we set the rewrite rule for that. If it's not then we must have a product, so we set the rewrite rule for that.

Either way, we then flush the rewrite rules, which makes WordPress take note of the updated values. (Note that the rewrite key is the same for both, so they over-write each other.)

Doing a flush_rewrite_rules() is described as "expensive" (i.e. slow) in the WP docs, but when testing I couldn't see any significant increase in the TTFB when doing this. (This may be because I'm adding false to do a 'soft flush' which according to the docs doesn't update .htaccess -- though I don't see the rewrites in .htaccess either way, but that's another topic.)

And anyway, on this site all output is cached with .htaccess rewriting (WP Super Cache's "expert mode" which means PHP and WP aren't even started if the requested page is in the cache already) so any slowdown would only affect the first request.

  • If nobody has any objections within a few days, I'll mark this as the answer. Is there a way I can give points to the other answer, as they did lead me to this solution?
    – JoLoCo
    Commented Jan 29 at 22:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.