Allow a varying number of categories in the post permalink structure
tldr; I have all the code explained below in a single file plugin. Check out the Multi Category Permalinks plugin on my Github.
The Problem
I set out to solve a problem I come across every time I work on a larger site. When I say larger site I am talking about launching with around 60-70 content pieces with another 20-30 planned. With sites this large that are growing every day it is incredibly important to make sure search engines can see how all the content pieces relate to each other. Often times I’ll work with a team of SEOs that want to be able to silo that content and make sure the URL structure reflects that.
What it comes down to is needing the ability to place posts on URLs like:
https://example.com/snippets/this-is-a-post/
https://example.com/snippets/javascript/this-is-another-post/
https://example.com/snippets/javascript/es6/this-is-a-third-post/
WordPress makes this difficult out of the box. There are some plugins that will allow you to set your permalinks on a per post basis, but that ends up putting that decision on the content writers/editors. After a year or two, you sort of end up with a mess of urls that don’t actually relate to each other because of misspellings and disorganization. My thought is if you could use WordPress Categories to build the URL structure, you’d be in a lot better shape. After a bit of research and manually structuring the post link and adding a rewrite rule, I built a solution that allows me to do just that.
The Solution
There are two basic pieces to the solution; the rewrite so WordPress can find the correct post based on the URL, and setting the post link so WordPress sends the user to the correct URL.
To set this up I started with a fresh WordPress install, made a few dummy posts, and set up some categories. The categories followed a parent-child structure like the following:
- Snippets
- JavaScript
- ES6
- PHP
- JavaScript
I set one post to a single category “Snippets”. The next I set to “Snippets” and “JavaScript”. A third I set to “Snippets”, “JavaScript”, and “ES6”. and the last post I set to “Snippets” and “PHP”. You can see the admin screen for the third post I mentioned below.
The Rewrite
At the end of the day, WordPress differentiates between posts using the slug (or post_name). It does cross reference the post category as well, but since no two posts can have the same slug, I put the most weight there. The code below tells WordPress to look at the first item in the URL and look for a category, and to look at the last item in the URL and look for the slug (or post_name). It ignores everything in between. This would be placed in a plugin, or in the themes functions.php file.
function mcp_add_rewrite_rule() {
add_rewrite_rule( '(.+?)/[a-z-]*?([^/]+)/?$', 'index.php?taxonomy=category&term=$matches[1]&name=$matches[2]', 'bottom' );
}
add_action( 'init', 'mcp_add_rewrite_rule' );
If the URL matches the regular expression in the first parameter of the add_rewrite_rule function, WordPress looks for a post using the query string in the second parameter. If it finds one it serves that post, and if not it moves on to the next rewrite rule. The third parameter lets WordPress know when it should check the rewrite rule. We want it checked last because the regular expression is pretty broad. If the first parameter had a value of ‘top’, it would match for a lot of things we didn’t want it to match
With just the code above, WordPress would display the correct post as long as the first item in the URL was the slug of one of the selected categories and the post slug matched. Any of the following URLs would display the correct post.
https://example.com/snippets/this-is-a-post/
https://example.com/javascript/this-is-a-post/
https://example.com/es6/this-is-a-post/
It’s close to what we want, but the problem is WordPress would still list this post’s URL based on the permalink structure set in the site’s settings. The sitemap would display the post link incorrectly as well, so while the post technically resolves, it does not really solve our problem. We need to tell WordPress to set the post link according to what categories are selected.
The Post Link
The post resolves, but we need WordPress to use the correct link based on the selected categories as well. The post link shows up in a lot more than just the front end and the sitemap. It’s also in the admin whenever you want to view a particular post. WordPress provides the post_link filter to allow you to change the permalink for posts. We’ll use it to reflect the new rewrite rule we added above.
To sum it up, we want to filter the post link, and based on the selected categories, build the new link that we actually want. To do that we’ll need to get the categories attached to the post, sort them into the desired order, and apply their slug to the url. The function below will do just that.
function mcp_modify_post_link( $permalink, $post ) {
$home_url = get_home_url();
// Get the list of terms (categories) attached to this post.
$categories = get_the_terms( $post->ID, 'category' );
// Pluck each term's ID into its own array.
$category_ids = array_column( $categories, 'term_id' );
// Get terms in order of heirarchy, but limited to the terms attached to this post.
$categories = get_terms(
array(
'taxonomy' => 'category',
'orderby' => 'parent',
'include' => $category_ids
)
);
$url_base = '';
$last_term = null;
if ( ! empty( $categories ) ) {
$url_base = '/';
foreach ( $categories as $key => $term ) {
// This has to be the first term in the list or a child of the previous term to be appended.
if ( null === $last_term || $term->parent === $last_term ) {
$url_base .= 0 === $key ? $term->slug : '/' . $term->slug;
$last_term = $term->term_id;
}
}
}
$permalink = $home_url . $url_base . '/' . $post->post_name . '/';
return $permalink;
}
add_filter( 'post_link', 'mcp_modify_post_link', 10, 2 );
There are a couple comments to outline what is going on in the code. One of the first things we do is get the categories assigned to the post. This is not too difficult as the post_link filter provides the WP_Post object. The only problem is that when getting any terms assigned to a post, you are not guaranteed to get them in the order you need them in. We don’t want our URL structured based on alphabetical order and while we could compare parent-child relationships, that could mean looping over the categories multiple times to get it right. Instead, we can get a list of ALL categories from WordPress ordered by parent-child relationship, and we can limit those results by just the categories attached to the post in question. The rest of the code grabs the slug from each category, appends it to our new URL, then returns the final URL complete with the domain and post name.
The last thing left to do would be to save your new permalink structure. You can do so in the WordPress admin by going to Settings > Permalinks, and clicking “Save Changes” at the bottom of the page.
Thats it! We got this done in two functions. It’s pretty easy to apply this to a custom taxonomy if you wanted to as well. None of the functions used are specific to the category taxonomy. They would all take any other defined taxonomy.
If you’d like to see the complete code, I have implemented it as a plugin, complete with permalink flushing on activation and deactivation. Feel free to grab the code or download as a plugin and use it on your website. Check out the Multi Category Permalinks plugin on my Github.