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.