Aryan Jasala

WordPress rewrite rules, permalinks and transients

A signpost pointing a pretty permalink at index.php

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.

Why a slug change does nothing
Slug changedregister_post_type rewrite argument
Nothing happenswp_options still holds old rules
Flush the rulesflush_rewrite_rules() or save permalinks
Row rebuiltrewrite_rules deleted, then regenerated
New URL resolvesthe archive finally returns posts

Rules live in the database, not .htaccess. Generating them is expensive, which is why WordPress refuses to do it per request.

Rewrite rules are stored in wp_options, so a slug change in code does nothing until the stored row is flushed and rebuilt.

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_rules holds the generated rules
  • wp_options -> permalink_structure holds 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. ๐Ÿšฆ

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. In mysite.local/test/%post_name% the front is test
  • $index -> the entry point every request is routed to
  • $permalink_structure -> the structure for posts
  • $author_base and $search_base -> the bases for author and search archives, and writable
  • $rules -> the generated rules
  • $extra_rules and $extra_rules_top -> anything added through add_rewrite_rule(), the second lot going in above core’s
  • $extra_permastructs -> structures added with add_permastruct(), which is where custom post types land
  • $endpoints -> anything from add_rewrite_endpoint()
  • $non_wp_rules -> rules that do not route through index.php at all. Added with add_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 .htaccess after 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. ๐Ÿ™ƒ

Transient expiry decides autoload

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.

Omitting the expiry is what makes a transient autoload on every request; adding one sets autoload to false.

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.