Every WordPress request lands on index.php, yet the browser shows /movie/iron-man/, a path that does not exist on disk. Rewrite rules close that gap. They are cached in the database, which is why changing a slug in code does nothing until they are flushed. This covers where the rules live, post type slugs, permastructs, flushing and transients.
Where the rules live
Generating the rules is expensive, so WordPress stores them:
wp_options->rewrite_rulesholds the generated ruleswp_options->permalink_structureholds the current structure
On Apache, .htaccess only gets the catch-all block: anything that is not a real file or directory goes to index.php. The actual rules never leave the database.
The rewrite object is the global $wp_rewrite, from wp-includes/class-wp-rewrite.php. It builds and stores the rules. The matching happens elsewhere: WP::parse_request() loads the rules from $wp_rewrite, finds the first pattern that matches the requested path, and turns it into query vars for WP_Query.
Permalink structures
The presets are Plain, Day and name, Month and name, Numeric and Post name, plus Custom Structure. A custom structure composes from tags:
%year% %monthnum% %day% %hour% %minute% %second%
%post_id% %postname% %category% %author%These tags build the post permalink and nothing else. Categories and tags get their own bases, custom post types and taxonomies get their own permastructs, and pages use %pagename%.
The properties worth knowing
$wp_rewrite declares around 30 properties. The ones that come up in real work:
$front-> everything in the post structure before the first tag. For/test/%postname%/the front is/test/$index->index.php, where every request is routed$permalink_structure-> the structure for posts$author_baseand$search_base->authorandsearchby default, and public, so writable$rules-> the generated rules$extra_rules_topand$extra_rules->add_rewrite_rule()with'top'or the default'bottom'. Custom post type and taxonomy rules also go in at the top$extra_permastructs-> structures added withadd_permastruct(), which is where custom post types land$endpoints-> anything fromadd_rewrite_endpoint()$non_wp_rules-> rules that skipindex.phpentirely. Added withadd_external_rule(), and these really are written into.htaccess$use_verbose_rules-> true if every WordPress rule should go into.htaccessafter all
Overriding a structure in code
get_page_permastruct() returns the property as-is when it is set, so assigning to it is the override:
function modify_permalink_structures() {
global $wp_rewrite;
$wp_rewrite->page_structure = 'test-pages/%pagename%';
}
add_action( 'init', 'modify_permalink_structures' );Rewrite for a custom post type
register_post_type() takes rewrite as a boolean or an array, alongside the rest of its arguments. The default true uses the post type key as the slug. false registers no rewrite rules at all, and the links fall back to ?mlb_movie=iron-man. The array is usually what we want:
'rewrite' => array(
'slug' => 'movie',
'with_front' => false,
),
'has_archive' => 'moviezz',slug sets the path segment. with_front defaults to true, so with a front of /prefix/ the URL would be /prefix/movie/test-movie/. Set to false, it is /movie/test-movie/. has_archive takes a string as well as a boolean, and the string becomes the archive slug. The zz was a 2022 decision.
Putting a taxonomy in the URL
add_permastruct() and remove_permastruct() are thin wrappers around the same methods on $wp_rewrite. Changing an existing structure means reading it, removing it, and adding it back. Run it after the post type is registered, which for a plugin is usually init at 10, so 11:
function mlb_movie_permastruct() {
global $wp_rewrite;
$args = $wp_rewrite->extra_permastructs['mlb_movie'];
remove_permastruct( 'mlb_movie' );
unset( $args['struct'] );
add_permastruct( 'mlb_movie', 'movie/%genre%/%mlb_movie%', $args );
}
add_action( 'init', 'mlb_movie_permastruct', 11 );The 2022 version of this snippet used %genre%/%mlb_movie% with no fixed prefix. Post type rules go in at the top of the list, so every two-segment URL on the site, child pages included, would be read as a genre and a movie. Keep a literal segment in front.
The rule is only half of it. get_permalink() replaces %mlb_movie% and leaves %genre% in the link as literal text, so the tag needs a filter:
function mlb_movie_link( $link, $post ) {
if ( 'mlb_movie' !== $post->post_type ) {
return $link;
}
$terms = get_the_terms( $post, 'genre' );
$genre = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0]->slug : 'uncategorised';
return str_replace( '%genre%', $genre, $link );
}
add_filter( 'post_type_link', 'mlb_movie_link', 10, 2 );Flushing, and why nothing happened
Change any of the above and the site behaves exactly as before. The new archive at /moviezz/ is a 404, because the rules in the database are still the old ones. Not a bug, a cache.
flush_rewrite_rules();That calls flush_rules() on $wp_rewrite. Since WordPress 6.4 it regenerates the rules and updates the rewrite_rules option in place. Its docblock still says it removes the option first. Called before wp_loaded, it waits for wp_loaded so every plugin has registered its rules. The default is a hard flush, which also rewrites .htaccess on Apache; flush_rewrite_rules( false ) only touches the option.
Visiting Settings, then Permalinks, calls flush_rewrite_rules() on every page load, not only on save. That is why “just save your permalinks” fixes so many bug reports. Clicking Save is optional.
function mlb_activate() {
mlb_register_movie_post_type();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mlb_activate' );Transients, since we are in wp_options
Without a persistent object cache, transients live in the same table, and one transient is two rows:
_transient_timeout_{name}-> the expiry timestamp_transient_{name}-> the value
With a persistent object cache, set_transient() calls wp_cache_set() instead and wp_options never sees it.
Set a transient with no expiry, then set the same name again with one, and core deletes and recreates it rather than updating it. That is not folklore. It is a comment in set_transient(): “If expiration is requested, but the transient has no timeout option, delete, then re-create transient rather than update.”
The same function has a second consequence, and it matters more. A transient with no expiry is added with autoload on. Give it an expiry and autoload is off.
What this does not cover
add_rewrite_rule() and add_rewrite_endpoint() in any depth, query var registration, and how object caches store transients.

Leave a comment