Every WordPress request goes to index.php, and yet the browser shows /movie/iron-man, a path that does not exist on disk. Rewrite rules are what close that gap, they live in the database rather than in .htaccess, and that is why changing one appears to do nothing. This covers the rewrite API, custom post type slugs, flushing, and where transients sit while you are in wp_options anyway.
Rules live in the database, not .htaccess. Generating them is expensive, which is why WordPress refuses to do it per request.
Where the rules actually live
Rewrite rules are generated by PHP, not written out to .htaccess, and generating them is expensive enough that WordPress refuses to do it per request. So they are stored:
wp_options->rewrite_rulesholds the generated ruleswp_options->permalink_structureholds the current structure
The rewrite object is global, at $wp_rewrite, and the class is wp-includes/class-wp-rewrite.php. It parses the incoming request into query vars and hands them to the query. ๐ฆ
Permalink structures
The presets are Plain, Day and Name, Month and Name, Numeric, and Post Name. Custom structures compose from tags:
%year% %monthnum% %day% %hour% %minute% %second%
%post_id% %postname% %category% %author%These apply to posts, taxonomies and custom post types. Pages and attachments do their own thing. ๐
The properties worth knowing
$wp_rewrite carries more than 20 properties. The ones that come up in real work:
$front-> the static string prefixed to post permalinks. Inmysite.local/test/%post_name%the front istest$index-> the entry point every request is routed to$permalink_structure-> the structure for posts$author_baseand$search_base-> the bases for author and search archives, and writable$rules-> the generated rules$extra_rulesand$extra_rules_top-> anything added throughadd_rewrite_rule(), the second lot going in above core’s$extra_permastructs-> structures added withadd_permastruct(), which is where custom post types land$endpoints-> anything fromadd_rewrite_endpoint()$non_wp_rules-> rules that do not route throughindex.phpat all. Added withadd_external_rule(), and these genuinely are written into the mod_rewrite section of.htaccess$use_verbose_rules-> true if the WordPress rules should go to.htaccessafter all
Overriding a structure in code
The get_*_permastruct() functions read straight off the global, so assigning to the property is the override:
function modify_permalink_structures() {
global $wp_rewrite;
$wp_rewrite->page_structure = 'test-pages/%pagename%';
}
add_action( 'init', 'modify_permalink_structures', 11 );Priority 11, because post types register on init at the default 10 and you need to be after whatever you are modifying. ๐
No expiry
- Added with autoload set to true
- Loaded into memory every request
- No timeout row stored
- Adding an expiry later recreates it
With expiry
- Added with autoload set to false
- Kept out of the autoloaded options
- Two rows: timeout and value
Both forms default to wp_options. Core deletes and re-creates rather than updates when a timeout is added to a transient that had none.
Rewrite for a custom post type
register_post_type() takes rewrite as a boolean or an array, alongside the rest of its arguments. As false you lose the ability to set the slug at all, so the array is what you want:
'rewrite' => array(
'slug' => 'movie',
'with_front' => false,
),slug sets the path segment. with_front decides whether the front string gets prepended, so with a front of /prefix/ and with_front set to false, the URL comes out as /movie/test-movie/ with no prefix.
The archive slug is separate, and has_archive takes a string as well as a boolean:
'has_archive' => 'moviezz',Adding and removing structures
add_permastruct() and remove_permastruct() are thin wrappers. Both call the matching method on $wp_rewrite, which adds to or removes from $extra_permastructs. Changing an existing one means reading it, removing it, and adding it back:
function add_movie_rewrite_rules() {
global $wp_rewrite;
$args = $wp_rewrite->extra_permastructs['mlb_movie'];
remove_permastruct( 'mlb_movie' );
unset( $args['struct'] );
add_permastruct( 'mlb_movie', '%genre%/%mlb_movie%', $args );
}
add_action( 'init', 'add_movie_rewrite_rules', 11 );Flushing, and why nothing happened
Change any of the above and the site behaves exactly as it did before. The new archive at /moviezz returns nothing. The rules in the database are still the old ones.
flush_rewrite_rules();That calls flush_rules() on the rewrite class, which deletes the rewrite_rules row and calls wp_rewrite_rules() to build and store a fresh set. Visiting Settings then Permalinks does the same thing, which is why “just save your permalinks” fixes so many bug reports. ๐
Do not call it on every request. It is the expensive operation the storage exists to avoid.
Transients, since we are in wp_options
Transients default to the same table, and one transient is two rows:
_transient_timeout_{name}-> the expiry timestamp_transient_{name}-> the value
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.”
There is a second consequence in the same function, and it matters more than the first. A transient with no expiry is added with autoload set to true. Give it an expiry and autoload is false. So a cache entry you thought was cheap is being loaded into memory on every single request, and the fix is an expiry time. ๐คก
What this does not cover
add_rewrite_rule() and add_rewrite_endpoint() in any depth, query var registration, and object-cached transients, which do not touch wp_options at all.
