Handling specific and ambiguous URL rewrite rules in WordPress
by Matt Cohen in Coding, Tutorials, WordPress. 6 min read.

Over the years I’ve picked up a few tips and tricks about handling custom URL rewrite rules in WordPress, a very important yet often not too well known aspect of WordPress which can help to provide the exact user experience for visitors.
I decided recently to experiment with handling a set of rewrite rules which are the same structure, yet cater for two different data types. This requires more than a well structured set of rewrite rules. This post serves to help share how to do this, as well as to remind my future self.
In my example scenario, this involved a set of photo albums (a taxonomy), and a set of photos (post type) connected to that taxonomy. The URL structures are the same. Photo albums would be something like /photos/animals/cats/
(where animals
and cats
are nested photo albums) and individual photos would be something like /photos/animals/cats/george/
where george
is the slug of the photo post, and animals
and cats
are nested photo albums.
The confusion comes in when WordPress needs to know how to proceed. Is the final piece of the URL a photo or a photo album? With this structure, it’s not directly possible to tell via regex alone, from what I’ve seen.
WordPress allows for modifying the main query before it is executed. This can use the URL structure to help WordPress decide how to proceed before loading the page. What I opted for is quite simple, yet effective in assigning the correct data type while also preserving pagination for photo album taxonomy term archives, etc.
The PHP code involved in determining the data type is as follows:
add_filter( 'pre_get_posts', 'photo_albums_parse_request' );
function photo_albums_parse_request( $query ) {
if ( ! is_admin() && $query->is_main_query() && isset( $query->query_vars['post_type'] ) && 'photo' === $query->query_vars['post_type'] ) {
$potential_photo = $query->get('name'); // Safely retrieve 'name' query var
if ( ! empty( $potential_photo ) ) {
// Attempt to match a single photo post
$args = [
'name' => $potential_photo,
'post_type' => 'photo',
'post_status' => 'publish',
'posts_per_page' => 1,
];
$posts = get_posts( $args );
if ( ! empty( $posts ) ) {
// Found a photo - set query for single post view
$query->set( 'name', $potential_photo );
$query->set( 'post_type', 'photo' );
$query->is_single = true;
$query->is_archive = false;
$query->is_tax = false;
} else {
// Attempt to match a taxonomy term (category)
$term = get_term_by( 'slug', $potential_photo, 'photo-category' );
if ( $term ) {
// Valid category path - set up category archive query
$query->set( 'photo-category', $potential_photo );
$query->set( 'post_type', 'photo' );
$query->set( 'paged' , 1 );
unset( $query->query['photo'] );
unset( $query->query['name'] );
$query->query['photo-category'] = $potential_photo;
unset($query->query_vars['photo']);
unset($query->query_vars['name']);
$query->query_vars['photo-category'] = $potential_photo;
// Reset query flags for taxonomy archive
$query->is_single = false;
$query->is_singular = false;
$query->is_archive = true;
$query->is_tax = true;
}
}
}
}
return $query;
}
By default, WordPress will set the name
query variable to the final string in the URL. We pull this out by getting the query variable if it is set, and then checking if we have a photo post with a matching slug. If we do, load it and set the query variables to match what a single post should be. If we don’t have a matching photo, attempt to check if we have a matching taxonomy term in our photo albums taxonomy. If we do, set up the query variables as needed to match a taxonomy term archive, and handle the pagination. I tried setting a custom posts_per_page
value here, though this would also require recalculating pagination so I’ve opted to stick with the standard settings for this example to focus on the delegation piece.
All of this also needs a set of rewrite rules to attempt to match the URL, and also to add our nested taxonomy term structure into the URLs for photo posts.
The rewrite rules, executed in the order they are declared, are:
// Add rewrite rules for single photos and albums
add_action('init', function () {
// Rule for nested categories with pagination
add_rewrite_rule(
'^photos/(.+?)/page/([0-9]+)/?$',
'index.php?photo-category=$matches[1]&paged=$matches[2]',
'top'
);
// Rule for single photos (most specific)
add_rewrite_rule(
'^photos/(.+?)/([^/]+)/?$',
'index.php?photo-category=$matches[1]&photo=$matches[2]',
'top'
);
// Rule for nested categories without pagination
add_rewrite_rule(
'^photos/(.+)/?$',
'index.php?photo-category=$matches[1]',
'top'
);
// Rule for photo post archive pagination
add_rewrite_rule(
'^photos/page/([0-9]+)/?$',
'index.php?post_type=photo&paged=$matches[1]',
'top'
);
});
To set up our nested URLs for our photos post type, we can filter on post_type_link
as follows:
add_filter( 'post_type_link', 'photos_post_type_link', 10, 3 );
function photos_post_type_link( $post_link, $post ) {
if ( 'photo' === $post->post_type ) {
if ($terms = get_the_terms( $post->ID, 'photo-category' ) ) {
// Sort terms by parent and term ID to get the hierarchy.
$parents = get_term_parents_list( $terms[0]->term_id, 'photo-category', array( 'format' => 'slug', 'separator' => '/', 'link' => false, 'inclusive' => true ) );
$post_link = str_replace( '%photo-category%', trim( $parents, '/' ), $post_link );
} else {
// Fallback if no category is assigned
return str_replace( '%photo-category%', 'uncategorized', $post_link );
}
}
return $post_link;
}
This filter pulls in the first matched term (the deepest album is the one photos are assigned to), fetches the term parents slugs with a /
as a separator, and does a string replacement on our custom URL rewrite rule. The trick here is to set the rewrite settings correctly when registering the taxonomy and the post type.
For the taxonomy, we set the rewrite
parameter as follows:
'rewrite' => [
'slug' => 'photos', // Make taxonomy URLs start with /photos/
'with_front' => false,
'hierarchical' => true,
],
For the post type, we set the rewrite
parameter as follows:
'rewrite' => [
'slug' => 'photos/%photo-category%', // Use the photo-category rewrite tag
'with_front' => false,
],
This placeholder we set when registering the post type is what is replaced when we filter on post_type_link
.
All of the above combined, along with refreshing permalinks by visiting the “Settings -> Permalinks” page in your WP Admin, results in the URL structure I desired (similar structures for both the taxonomy terms and the photo posts).
I’m sure there’s a way to do this with even less code. If there is, please share your feedback in the comments below.