This is a fascinating API. It is so fundamental to how WordPress works, yet so few people actually understand it. You are about to learn in detail how WordPress’s pretty permalinks work and how you can create your own custom URL structures. Hopefully, this knowledge will elevate your understanding of WordPress internals, which, as you might remember, is the entire point of this guide.
Remember what I said about parsing the URL when I was discussing the REST API? I said that core WordPress developers were smart and instead of handling pretty permalinks differently than plain permalinks, they just “moved” the route to the “?rest_route=” query parameter and treated it like a plain permalink. Well, it turns out that’s the way the entire rewrite system works, including all your pretty URLs to posts and pages.
add_rewrite_rule()
To explain it, I’ll start by introducing the single most important function in the entire Rewrite API: add_rewrite_rule(). This function adds a regex rewrite rule. Here’s how you could use it:
add_rewrite_rule('^/post/(.*)?', 'index.php?post_type=post&name=$matches[1]', 'top');Adding this rewrite rule will make it so that visiting example.com/post/post-name will be equivalent to visiting example.com/index.php?post_type=post&name=post-name. This is how all pretty permalinks work in WordPress. When you set the permalink structure in settings to anything else than plain, all you’re doing is triggering a set of those rewrite rules to be added. The internals of WordPress operate on the plain structure (with query string parameters) only.
The third parameter – “top”, makes it so that this rewrite rule gets added at the top of the list of all registered rewrite rules. Rewrite rules are checked from the top to the bottom, and the first one matching wins. It’s to ensure that our custom rule isn’t beat by some core rule. The other option is “bottom”.
Query Variables
The path is always index.php, as that’s WordPress’s front controller. But what are those query parameters, like “post_type” and “name”? Well, their name in the WordPress world is “query variables”. The global $wp object has a $query_vars array. This array is populated with all query vars found while parsing the rewritten URL. After parsing the previous example URL, this array would be equal to:
array( 'post_type' => 'post', 'name' => 'post-name' );The reason it works this way is actually pretty simple. Remember what happens after the URL is parsed? The main query is run with WP_Query. Now, instead of having to do god knows what to construct the query, all WordPress has to do is read from this array, like this (conceptually):
$args = [
// [...]
'post_type' => $query_vars['post_type']
'name' => $query_vars['name']
// [...]
]
new WP_Query( $args );Do you see how brilliant this is? We’ve translated a pretty URL that’s hard for computers to interpret (/post/post-name) into a URL that’s ugly to humans but is basically just a list of key-value pairs (?post_type=post&name=post-name). Then, we just use these keys and values (query vars) in all further processing of the request, not having to worry in the slightest about what the original URL looked like. That’s the power of the Rewrite API.
This is the general idea, but query vars have some more complexity tied to them. First, not all query parameters will become query variables. The $wp object has another array – $public_query_vars. This array is basically a whitelist of all allowed query vars. It’s long, but some of the most important ones include:
- p (post id)
- category_name
- search
- name
- author_name
- page_id
- post_type
- and more…
If you visited example.com/?arbitrary=abc, there would be no $query_var[‘arbitrary’], as the key “arbitrary” is not whitelisted. That being said, “rest_route” is not included in the default whitelist, so how does it work? Meet $wp->add_query_var(). This method allows you to add a custom query variable to the list of all allowed query vars.
You should now know enough to be able to understand the code snippet responsible for making the REST API’s permalink structure work. Here it is (a little simplified):
global $wp;
$wp->add_query_var( 'rest_route' );
add_rewrite_rule( '^' . rest_get_url_prefix() . '/?$', 'index.php?rest_route=/', 'top' );
add_rewrite_rule( '^' . rest_get_url_prefix() . '/(.*)?', 'index.php?rest_route=/$matches[1]', 'top' );What this does is it registers “rest_route” as a whitelisted query variable and then adds rewrite rules to translate the pretty URLs to the plain URLs. The root REST API URL (example.com/wp-json/) is translated to ?rest_route=/, and if there’s a route (e.g., example.com/wp-json/wp/v2/posts), it becomes the value of rest_route (i.e., example.com/index.php?rest_route=/wp/v2/posts).
But what does the REST API do with this since it doesn’t run WP_Query? Well, remember the rest_api_loaded() function? Here’s how it uses it to check if the request is for the REST API:
if ( empty( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
return;
}And here’s how it then reads the route to pass it to $server->serve_request():
$route = untrailingslashit( $GLOBALS['wp']->query_vars['rest_route'] );
if ( empty( $route ) ) {
$route = '/';
}
$server->serve_request( $route );That shows perfectly that it’s entirely up to you as to how you use query vars. Some query vars, or maybe even all of the default ones, are used when constructing the main WP_Query. But that’s not a rule. You can register new rewrite rules, capture the values from the URL to your own query vars, and use them to return any arbitrary content (like the REST API does).
PS: You can also whitelist custom query variables using the “query_vars” filter.
Parsing The URL Step By Step
I want you to really understand it, which is why I’m going to go even closer to the actual implementation. All of what we’re talking about happens in WP::parse_request() (stage 6 of the request lifecycle). Here’s an ordered list of things done by this function:
- Fetch all registered rewrite rules (from $wp_rewrite, which is a global instance of WP_Rewrite).
- Prepare the URL path (“https://example.com/post/post-name” becomes “post/post-name”)
- Iterate over all rewrite rules and try to match them with the path using preg_match(). The first matching rule is saved and breaks out of the loop.
- Convert the path (“post/post-name”) into the plain path defined in the matched rule (index.php?post_type=post&name=post-name).
- Run this plain path through parse_str() to get an array of query parameters (i.e,.
array( ‘post_type’ => ‘post’, ‘name’ => ‘post-name’ )). - Iterate through $public_query_vars (the list of all allowed query vars) and try to find and add them to $query_vars from one of these, in order:
- $_POST
- $_GET
- The array of query parameters we just created with the rewrite rule.
That’s it. After this method runs, we will have our $query_vars array populated with all query variables present in the parsed plain URL, which was, in turn, created from the pretty URL using rewrite rules we’ve registered.
You might’ve noticed something interesting. $_POST and $_GET are checked before the array generated with rewrite rules. This has some interesting consequences. As an example, take this URL: example.com/wp-json/wp/v2/posts/1?rest_route=/wp/v2/posts/2. What post ID will it return data for – 1 or 2? The answer is 2.
This is also why example.com?p=123 will always render the page for post with ID 123, even if pretty permalinks are enabled (although it will redirect you to the appropriate pretty URL), whereas example.com/post-title-of-post-123 will throw an error as soon as pretty permalinks are switched off. Query variables passed as if the permalinks structure was plain will always work.
Example
Let’s imagine you were creating a website for a library. Every book in the library was placed on a shelf. You wanted to allow visitors to display all books on a given shelf by navigating to example.com/shelf/{shelf_id}, where shelf_id is something like “a”, “b”, etc. Let’s also imagine that, for some reason, the list of all books stored on a given shelf is kept in a custom database table. Here’s how you’d manage adding that URL structure:
function pgn_configure_shelf_rewrite() {
global $wp;
$wp->add_query_var( 'shelf' );
add_rewrite_rule( 'shelf/(.*)?', 'index.php?shelf=$matches[1]', 'top' );
}
add_action( 'init', 'pgn_configure_shelf_rewrite' );
function pgn_filter_posts_by_shelf( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
$shelf = get_query_var( 'shelf' );
if ( !isset( $shelf ) ) {
return;
}
global $wpdb;
$book_ids = $wpdb->get_col( $wpdb->prepare(
"SELECT book_id FROM custom_shelf_table WHERE shelf_code = %s",
$shelf
) );
$query->set('post_type', 'pgn_book');
$query->set('post__in', $book_ids);
});
add_action( 'pre_get_posts', 'pgn_filter_posts_by_shelf' );This code:
- Adds “shelf” to the list of whitelisted query vars.
- Registers a rewrite rule transforming /shelf/a into index.php?shelf=a.
- Modifies the main WP_Query object to include only posts with IDs fetched from the custom table.
Of course this example is completely superfluous. The fact that IDs of books on given shelves are stored in a custom table is the first thing that should raise your eyebrows. This functionality can be achieved perfectly with just a custom “Shelves” taxonomy.
How would you then make this URL structure work? Well, I’m pleased to say you wouldn’t need to use the Rewrite API. You probably don’t remember, but when you’re registering a taxonomy with register_taxonomy(), one of the $args options is “rewrite”, which lets you set the slug of the taxonomy (it’s the same for custom post types). Making this structure work would therefore be as simple as setting ‘rewrite’ => [ ‘slug’ => ‘shelf’ ].
But let’s assume you were an idiot and you wanted to flex your newly acquired Rewrite API knowledge in front of your friends. At the end of the day, the more complicated the code the better, am I right? I’m once again pleased to say that you wouldn’t need to add a custom query variable. You could do all of that using only the default query vars that are already whitelisted and used in the main WP_Query. Here’s the code:
// assuming "pgn_shelf" is the name of your taxonomy (WordPress automatically registers it as a query var)
add_rewrite_rule( 'shelf/(.*)?', 'index.php?pgn_shelf=$matches[1]', 'top' );This is also how my previous example of rewriting /post/post-name to index.php?post_type=post&name=post-name worked. Both ‘post_type’ and ‘name’ are default whitelisted query vars used in the core for creating the main query. As a matter of fact, it’s very rare that you will ever need to work with custom query vars, which is why I had to resort to the overengineered custom table example.
How Rewrite Rules Are Stored
This part is important. Rewrite rules are not recomputed on every request. They are only calculated when you call flush_rewrite_rules() (which happens when you visit the Permalinks Settings page) and stored in the wp_options table with the “rewrite_rules” key. This option’s value is just a huge associative array of regexes and their plain equivalents, e.g., array( ‘shelf/(.*)?’ => ‘index.php?pgn_shelf=$matches[1]’ ).
This array contains all rewrite rules used in $wp->parse_request() when trying to match the path. The fact that it is only regenerated on flush_rewrite_rules() means that you should always call add_rewrite_rule() on the “init” hook, which will be called on the permalinks settings page. Otherwise, your rules will not get registered when rewrite rules are flushed this way (calling add_rewrite_rule() does basically nothing if flush_rewrite_rules() isn’t called during the same request, which is good).
But why are rewrite rules stored in the database? Why aren’t they computed in code for every request? The reason is very simple – performance. There’s more to computing rewrite rules than add_rewrite_rule(). The number of core rules is large and computing them all is expensive. The “rewrite_rules” option stored in the database is really big.
It would be extremely inefficient to recompute them every time, which is why you should absolutely never call flush_rewrite_rules() on every request (like on init). If your plugin adds custom rewrite rules, you should call flush_rewrite_rules() only on activation and deactivation hooks. This way, the user won’t have to manually flush them, and your plugin won’t negatively affect the performance of their website.
Permastructs & Rewrite Tags
When I said that “there’s more to computing rewrite rules than add_rewrite_rule()”, I was mostly referring to permastructs. A permastruct (or a permalink structure) is just a set of rewrite rules generated for a common base path. This concept is a little complex so it’ll be better if I lead with an example.
Think back to how we manually added a rewrite rule for our shelf taxonomy. That worked, but only for the first page… What if our taxonomy was paged, i.e., it allowed /page/2, /page/3, etc.? We didn’t add a rewrite rule for pages, so they wouldn’t work. That’s exactly why you shouldn’t do that manually, but more importantly, it shows why permastructs exist.
A permastruct is added using the add_permastruct() function. This function basically just registers a set of rewrite rules (you can specify which rules you want from a predefined list). It’s the exact function WordPress core uses to add rewrite rules when you register a custom taxonomy or a custom post type.
The base of a permastruct is defined using rewrite tags. A rewrite tag is just a placeholder that represents a variable in the URL. Rewrite tags are always wrapped around percentage signs (%). As a matter of fact, you can see a few default rewrite tags when you visit the Permalinks Settings page, like %year%, %post_id%, %postname%, %category%, etc. A rewrite tag is registered with add_rewrite_tag():
add_rewrite_tag( '%postname%', '([^/]+)', 'name=' );The above is the exact call that registers the %postname% rewrite tag. What it means is that if you use %postname% in a permastruct, it will be replaced with “([^/]+)”, and the captured value will be used as the “name” query var. That’s what happens when you enable custom structure in permalinks settings and use /%postname%/. example.com/abc/ gets translated to index.php?name=abc.
Rewrite tags are meant to be used in permastructs. That’s because add_permastruct() doesn’t allow custom regex the same way add_rewrite_rule() does. A rewrite tag is just a single variable that is used to create a permastruct’s base. A permastruct can use multiple tags, e.g., /%author%/%postname%/, which would become ([^/]+)/([^/]+) and would be mapped to index.php?author_name=$match[1]&name=$match[2].
Let’s go back to our example of manually adding the shelf rewrite rule, but let’s do it right this time – using add_permastruct() (it’s still a bad idea btw):
function pgn_register_shelf_permastruct() {
// Register the rewrite tag so WP knows how to turn %pgn_shelf% into a query var
add_rewrite_tag( '%pgn_shelf%', '([^/]+)', 'pgn_shelf=' );
add_permastruct(
'pgn_shelf', // unique name
'shelf/%pgn_shelf%', // structure template
array(
'with_front' => false, // don't prepend with $wp_rewrite->front
)
);
}
add_action( 'init', 'pgn_register_shelf_permastruct' );This will automatically add a family of rewrite rules, including:
- /shelf/a/ -> index.php?pgn_shelf=a
- /shelf/a/page/2/ -> index.php?pgn_shelf=a&paged=2
- /shelf/a/feed/rss2/ -> index.php?pgn_shelf=a&feed=rss2