Rewrite API and Permalinks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A pretty permalink such as /books/the-lighthouse-ledger/ is not a file and not a route table in the web server: the server hands every unknown path to index.php, and WordPress matches the path against an ordered list of regular expressions, the rewrite rules, each of which rewrites the path into query variables like lp_book=the-lighthouse-ledger. Those variables drive WP_Query, and the query result decides the template. The Rewrite API is the set of functions that adds rules, tags, permalink structures and endpoints to that list. This page explains how the list is built and stored, then adds two URLs to the lumen-catalog plugin of Lumen Press: a short /books/isbn/{isbn}/ address for print catalogues and QR codes, and a /reviews/ endpoint under every book. It covers the hooks that resolve and guard those URLs, the interaction with redirect_canonical(), when to flush, and how to debug rules with WP-CLI.

The page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; it does not change the Rewrite API so far. The API itself is old and stable: add_rewrite_rule(), add_rewrite_tag() and add_rewrite_endpoint() date from 2.1, array queries in add_rewrite_rule() from 4.4, and the custom query var name of add_rewrite_endpoint() from 4.3. Since 6.4 a rule rebuild requested before wp_loaded is postponed until every plugin has registered its rules. Every output below comes from a 7.1.3 site (official wordpress:7.1.3-php8.4-apache image, MariaDB 11.8, Twenty Twenty-Five 1.5, permalink structure /%postname%/) with the lumen-catalog plugin; the outputs were recorded over plain HTTP and are shown with the canonical https://lumenpress.test address. The setup of such a site is in Local Development Environments.

How a URL becomes a template

flowchart TD A["GET /books/isbn/978-1-23456-789-7/"] --> B["Web server: not a file or directory, so .htaccess or try_files sends it to index.php"] B --> C["WP::parse_request() loads rules with WP_Rewrite::wp_rewrite_rules() (the rewrite_rules option)"] C --> D{"First rule whose regex matches the path"} D -->|"^books/isbn/(...)/?$"| E["index.php?lp_isbn=$matches[1]"] D -->|no rule matches| X["error = 404"] E --> F["Public query vars only (query_vars filter), then request filter"] F --> G["WP_Query: post_type=lp_book, p=4 is_single() is true"] X --> G G --> H["template_redirect redirect_canonical() at priority 10, plugin guards, wp_safe_redirect()"] H --> I["Template hierarchy single-lp_book, single, singular, index"] I --> J["Block template or PHP template renders the page"]

The steps, in the order WP::main() runs them:

  1. The web server sends the request to index.php because no file matches. On Apache the # BEGIN WordPress block in .htaccess does it, on nginx a try_files line (see Web Servers: Apache and nginx). Adding a rule never needs a server change.

  2. WP::parse_request() strips the home path, gets the rules from $wp_rewrite->wp_rewrite_rules() and tests them in order with preg_match( "#^$match#", ... ), first against the raw path and then against its URL-decoded form. The first match wins; the rule’s query string, with $matches[n] replaced by the captured groups, becomes the "perma query vars". With a structure that starts with %postname%, core uses verbose page rules: the page rule (.?.+?)(?:/([0-9+))?/?$] comes before the post rules and matches almost anything, so for each page-rule match parse_request() calls get_page_by_path() and skips the rule when no such page exists.

  3. Only public query vars survive: $wp->public_query_vars filtered by query_vars, plus the query var of every viewable post type and taxonomy. Unknown variables from a rule or from the query string are dropped silently. The request filter then sees the final array and may change it.

  4. WP::query_posts() runs the main WP_Query with those variables, and WP::handle_404() sends a 404 status when the query found nothing.

  5. template-loader.php fires template_redirect, where core’s redirect_canonical() runs at priority 10, and then walks the template hierarchy (see Template Hierarchy).

WP_Rewrite internals

The global $wp_rewrite (class WP_Rewrite) holds the permalink structure, the rewrite tags such as %postname%, the extra permalink structures ("permastructs") of post types and taxonomies, the endpoints and the rules added by plugins. WP_Rewrite::rewrite_rules() generates the complete list from all of that; it is expensive (the test site has 130 rules), so the result is cached in the rewrite_rules option, an autoloaded array of regex => query pairs. WP_Rewrite::wp_rewrite_rules() returns the option and, when it is empty, regenerates the list and saves it (after wp_loaded since 6.4). Deleting the option is therefore a lazy flush: the next request rebuilds it. On the test site, the recreated option had autoload auto instead of on, which is still autoloaded.

The generated list is assembled in this order, which decides which rule wins when two regular expressions overlap:

Position Rules Filter

1

extra_rules_top: rules added with add_rewrite_rule( ..., 'top' ) (the REST API’s ^wp-json/ rules, the archive rules of post types such as books/?$), then the permastructs of post types and taxonomies in registration order

{permastructname}_rewrite_rules per permastruct

2

robots.txt, favicon.ico, sitemaps, deprecated feed files, registration pages

none

3

root, comments feed, search, author and date archives

root_rewrite_rules, comments_rewrite_rules, search_rewrite_rules, author_rewrite_rules, date_rewrite_rules

4

page rules and post rules (pages first when verbose page rules are on)

page_rewrite_rules, post_rewrite_rules

5

extra_rules: rules added with add_rewrite_rule() and the default 'bottom'

none

6

the whole list

generate_rewrite_rules action (receives $wp_rewrite by reference), then rewrite_rules_array

A rule whose target is not index.php is an external rule: it goes to non_wp_rules, is written only into .htaccess by a hard flush on Apache, and does nothing on nginx. Redirects of old URLs belong in the web server or in template_redirect instead.

Registering post types and taxonomies adds their permastructs only to the in-memory object; the stored option changes only when the rules are flushed (flush_rewrite_rules(), $wp_rewrite->flush_rules(), saving or even just loading Settings > Permalinks, or wp rewrite flush). The book post type and its books/genre and books/series taxonomies, and why the taxonomies must be registered first, are covered in Custom Post Types and Taxonomies.

The Lumen Press routes

Lumen Press prints a short URL and a QR code in its catalogues: /books/isbn/978-1-23456-789-7/ must show "The Lighthouse Ledger" whatever the book’s slug becomes. Reader reviews are comments on a book, shown on a separate page at /books/the-lighthouse-ledger/reviews/. Both live in a new class of lumen-catalog, src/Rewrites.php, autoloaded like the others (see Plugin Fundamentals):

<?php
/**
 * Custom URLs of the catalogue: /books/isbn/{isbn}/ and the /reviews/ endpoint of a book.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Registers the rewrite rules, the query vars and the request handling. */
final class Rewrites {

	/** A hyphenated ISBN-13, the form stored in lp_isbn. No anchors: the rule adds them. */
	const ISBN_REGEX = '(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])';

	/** Called from Plugin::boot() on plugins_loaded. */
	public static function boot(): void {
		add_action( 'init', array( self::class, 'register' ) );
		add_filter( 'query_vars', array( self::class, 'query_vars' ) );
		add_filter( 'request', array( self::class, 'resolve_isbn' ) );
		add_action( 'template_redirect', array( self::class, 'guard_reviews' ), 11 );
		add_filter( 'single_template_hierarchy', array( self::class, 'reviews_template' ) );
	}

	/** Hooked to init, and called by Installer::activate() before it flushes. */
	public static function register(): void {
		add_rewrite_rule(
			'^books/isbn/' . self::ISBN_REGEX . '/?$',
			'index.php?lp_isbn=$matches[1]',
			'top'
		);

		add_post_type_support( 'lp_book', 'comments' ); // Reviews are comments on the book.

		// Adds /reviews/ to every URL whose permastruct has EP_PERMALINK, lp_book included.
		add_rewrite_endpoint( 'reviews', EP_PERMALINK, 'lp_reviews' );

		if ( function_exists( 'register_block_template' ) ) {
			register_block_template(
				'lumen-catalog//single-lp_book-reviews',
				array(
					'title'   => __( 'Book Reviews', 'lumen-catalog' ),
					'content' => '<!-- wp:template-part {"slug":"header"} /-->'
						. '<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} --><main class="wp-block-group">'
						. '<!-- wp:post-title {"level":1,"className":"lp-reviews-title"} /-->'
						. '<!-- wp:post-comments-form /--></main><!-- /wp:group -->'
						. '<!-- wp:template-part {"slug":"footer"} /-->',
				)
			);
		}
	}

	/**
	 * Makes lp_isbn a public query var, so WP::parse_request() keeps it.
	 *
	 * @param string[] $vars Public query vars.
	 * @return string[]
	 */
	public static function query_vars( array $vars ): array {
		$vars[] = 'lp_isbn';
		return $vars;
	}

	/**
	 * Turns ?lp_isbn= into the query of one published book, or into a 404.
	 *
	 * @param array $query_vars Query vars parsed from the URL.
	 * @return array
	 */
	public static function resolve_isbn( array $query_vars ): array {
		if ( empty( $query_vars['lp_isbn'] ) ) {
			return $query_vars;
		}
		$ids = get_posts(
			array(
				'post_type'      => 'lp_book',
				'post_status'    => 'publish',
				'meta_key'       => 'lp_isbn', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_value'     => $query_vars['lp_isbn'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
				'fields'         => 'ids',
				'posts_per_page' => 1,
				'no_found_rows'  => true,
			)
		);
		if ( ! $ids ) {
			return array( 'error' => '404' );
		}
		return array(
			'post_type' => 'lp_book',
			'p'         => $ids[0],
			'lp_isbn'   => $query_vars['lp_isbn'],
		);
	}

	/** Hooked to template_redirect (priority 11, after redirect_canonical): /reviews/ exists only on books, without a trailing value. */
	public static function guard_reviews(): void {
		global $wp_query;
		if ( ! isset( $wp_query->query_vars['lp_reviews'] ) ) {
			return;
		}
		if ( ! is_singular( 'lp_book' ) || '' !== $wp_query->query_vars['lp_reviews'] ) {
			$wp_query->set_404();
			status_header( 404 );
			nocache_headers();
		}
	}

	/**
	 * Puts single-lp_book-reviews first in the hierarchy of a book's /reviews/ URL.
	 *
	 * @param string[] $templates Candidate templates.
	 * @return string[]
	 */
	public static function reviews_template( array $templates ): array {
		if ( isset( $GLOBALS['wp_query']->query_vars['lp_reviews'] ) && is_singular( 'lp_book' ) ) {
			array_unshift( $templates, 'single-lp_book-reviews.php' );
		}
		return $templates;
	}

	/**
	 * The short URL of a book, for print catalogues and QR codes.
	 *
	 * @param string $isbn Hyphenated ISBN-13.
	 */
	public static function isbn_url( string $isbn ): string {
		return home_url( user_trailingslashit( 'books/isbn/' . rawurlencode( $isbn ) ) );
	}
}

Plugin::boot() gains one line, Rewrites::boot();, after the post type and taxonomy hooks. The activation routine of Custom Post Types and Taxonomies registers the rules before its flush, because the init callbacks have not run in the activation request:

	// In Lumen\Catalog\Installer.
	public static function activate(): void {
		self::upgrade();

		Taxonomies::register();
		PostTypes::register();
		Rewrites::register();
		self::grant_capabilities();
		flush_rewrite_rules( false );
	}

add_rewrite_rule() and the query_vars filter

add_rewrite_rule( $regex, $query, $after ) takes three arguments:

  • $regex is matched against the path without the leading slash and without the query string. WordPress wraps it as #^...#, so the leading ^ in ^books/isbn/ is redundant but harmless (core’s own rules omit it, which is why wp rewrite list shows both styles). /?$ accepts the URL with or without the trailing slash; without the $, /books/isbn/978-1-23456-789-7/anything would match too. The capture group is what the route accepts: the ISBN pattern is the same one the lp_isbn meta schema enforces on Metadata and Custom Fields, so malformed values never reach PHP code and /books/isbn/not-an-isbn/ falls through to a 404.

  • $query is a query string starting with index.php?, with $matches[1], $matches[2]…​ for the groups. It must be written in single quotes: in double quotes PHP interpolates $matches as an empty variable and the rule silently maps to lp_isbn=[1]. Since 4.4 it can also be an array, which add_rewrite_rule() turns into the same string.

  • $after is 'bottom' by default. The Lumen Press rule needs 'top': the post type’s attachment rule books/[^/]+/([^/]+)/?$ matches any two segments after books/ and comes earlier in the list. With 'bottom' on the test site, /books/isbn/978-1-23456-789-7/ was read as an attachment named "978-1-23456-789-7" and returned 404:

$ wp rewrite list --match=/books/isbn/978-1-23456-789-7/ --fields=match,query,source --format=table
match                                                           query                              source
books/[^/]+/([^/]+)/?$                                          index.php?attachment=$matches[1]   lp_book
(.?.+?)(?:/([0-9]+))?/?$                                        index.php?pagename=$matches[1]&... page
^books/isbn/(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])/?$  index.php?lp_isbn=$matches[1]      other

The rule alone is not enough: lp_isbn is not a known query var, and parse_request() drops it. The query_vars filter adds it to the public list. Public means exactly that: a public variable is also accepted from the query string, so /?lp_isbn=978-1-23456-789-7 resolves the book too (200 on the test site), and the code must treat the value as untrusted input whichever way it arrived. Here the request filter only uses it as an exact meta value in a prepared query, so no extra sanitizing is needed; code that echoes a query var escapes it.

resolve_isbn() on the request filter turns the ISBN into the variables of a normal single-book query, post_type and p, so the main WP_Query loads the book, is_singular( 'lp_book' ) is true and the theme’s single-lp_book template renders it. An unknown ISBN returns array( 'error' => '404' ), which WP_Query treats as a 404, so the theme’s 404 template answers with status 404.

add_rewrite_tag() and add_permastruct()

A rewrite tag is a placeholder such as %postname% with a regular expression and a query string fragment. add_rewrite_tag( '%lp_isbn%', $regex ) registers the tag and, when the third argument is omitted, also adds lp_isbn as a public query var (so the query_vars filter becomes unnecessary). Tags are expanded only in permalink structures: the post structure of Settings > Permalinks and the extra structures added with add_permastruct(); a %lp_isbn% inside add_rewrite_rule() stays literal. The same route written as a permastruct:

	// Alternative to add_rewrite_rule() + query_vars, hooked to init at priority 9.
	add_rewrite_tag( '%lp_isbn%', self::ISBN_REGEX );
	add_permastruct(
		'lp_isbn',
		'books/isbn/%lp_isbn%',
		array(
			'with_front'  => false,
			'ep_mask'     => EP_NONE,
			'paged'       => false,
			'feed'        => false,
			'forcomments' => false,
			'walk_dirs'   => false,
			'endpoints'   => false,
		)
	);
$ wp rewrite list --source=lp_isbn --format=table
match                                                          query                          source
books/isbn/(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])/?$  index.php?lp_isbn=$matches[1]  lp_isbn

Without the false flags, a permastruct also generates feed, page, comment-page, embed and parent-directory rules, which a lookup route does not want. Permastructs are placed in registration order, and there is no 'top': registered on init at the default priority, after lp_book, the rule landed below the post type’s attachment rule and the URL returned 404; at priority 9, before the post type, it answered 200. add_permastruct() is the right tool when the URL is built from several tags and WordPress should also generate links, feeds and pagination for it, as post types and taxonomies do; a single lookup route is simpler with add_rewrite_rule().

add_rewrite_endpoint()

An endpoint appends a fixed segment to existing URLs, like core’s /feed/, /trackback/ and /embed/. add_rewrite_endpoint( 'reviews', EP_PERMALINK, 'lp_reviews' ) adds, for every permastruct whose endpoint mask includes EP_PERMALINK, a rule ending in /reviews(/(.*))?/?$. Post types use EP_PERMALINK by default, so lp_book gets it, and so do ordinary posts:

$ wp rewrite list --match=/books/the-lighthouse-ledger/reviews/ --fields=match,query,source --format=table
match                             query                                                    source
books/([^/]+)/reviews(/(.*))?/?$  index.php?lp_book=$matches[1]&lp_reviews=$matches[3]     lp_book
books/[^/]+/([^/]+)/?$            index.php?attachment=$matches[1]                         lp_book
(.?.+?)(?:/([0-9]+))?/?$          index.php?pagename=$matches[1]&page=$matches[2]          page

$ wp rewrite list --match=/hello-world/reviews/ --fields=match,query,source --format=table
match                         query                                               source
(.?.+?)(?:/([0-9]+))?/?$      index.php?pagename=$matches[1]&page=$matches[2]     page
([^/]+)/reviews(/(.*))?/?$    index.php?name=$matches[1]&lp_reviews=$matches[3]   post
[^/]+/([^/]+)/?$              index.php?attachment=$matches[1]                    post

The masks are bit flags combined with |: EP_PERMALINK (posts and post types), EP_PAGES, EP_ROOT, EP_CATEGORIES, EP_TAGS, EP_AUTHORS, the date masks, EP_ALL_ARCHIVES, EP_ALL and EP_NONE. A post type with a 'rewrite' => array( 'ep_mask' => ... ) argument can opt into a custom mask so an endpoint applies to that type only; lumen-catalog keeps the default and rejects other types in template_redirect instead.

The third argument names the query var (lp_reviews, prefixed so it cannot collide with another plugin’s reviews); false registers the rules without a query var. Two details of endpoint query vars cause most bugs:

  • At /books/the-lighthouse-ledger/reviews/ the variable is set to an empty string, and get_query_var( 'lp_reviews' ) returns '' both when the endpoint is present and when it is absent. Test presence with isset( $wp_query->query_vars['lp_reviews'] ), as the class does.

  • The (/(.*))? group accepts anything after the endpoint: /reviews/extra/ sets lp_reviews to extra. Code that does not expect a value must reject it, or it serves the same page under endless URLs.

Handling the request: template_redirect and the template

template_redirect is the last action before the template loads, and the place for redirects, access checks and status changes, because the main query has run and conditional tags such as is_singular() work. guard_reviews() turns /reviews/ on anything but a book, and /reviews/ with a trailing value, into a real 404 with $wp_query->set_404() and status_header( 404 ); the template loader then picks the 404 template. A redirect from this hook uses wp_safe_redirect() followed by exit, and a non-HTML response (a file download, JSON) prints and exits.

The template for the endpoint comes from the hierarchy filter: single_template_hierarchy puts single-lp_book-reviews.php in front of the candidates, and block themes look up single-lp_book-reviews among their templates from the same list. register_block_template() (6.7) gives every block theme a default for that slug, which a theme file templates/single-lp_book-reviews.html (for example in the lumen theme) or a user edit in the Site Editor overrides. With Twenty Twenty-Five, the reviews URL rendered the plugin template (the lp-reviews-title heading and the comment form) and the book URL kept the theme’s single template. Plugin templates and the hierarchy filters are covered in Template Hierarchy; a classic theme gets a PHP fallback through template_include.

redirect_canonical()

redirect_canonical() is core’s callback on template_redirect at priority 10. It sends a 301 to the canonical address when the requested URL is a known variant of it, and it guesses a target for some 404s. Observed on the test site:

Request Status Why

/books/the-lighthouse-ledger

301 to /books/the-lighthouse-ledger/

Trailing slash added by user_trailingslashit() to match the structure

/books/isbn/978-1-23456-789-7

301 to /books/isbn/978-1-23456-789-7/

Same rule; custom routes get it too

/?p=4, /?post_type=lp_book&p=4

301 to /books/the-lighthouse-ledger/

Plain query string of a post that has a pretty permalink

/books/the-lighthouse/, /the-lighthouse-ledger/

301 to /books/the-lighthouse-ledger/

A 404 whose slug is the start of a published post’s slug: redirect_guess_404_permalink()

/books/isbn/978-1-23456-789-7/

200

Custom route; redirect_canonical() leaves it alone

/?lp_isbn=978-1-23456-789-7, /?lp_book=the-lighthouse-ledger

200

Query vars that are not p, page_id, m, author and the like are not canonicalized

The ISBN page is therefore served at two addresses, the ISBN URL and the book’s permalink. Core prints <link rel="canonical" href="https://lumenpress.test/books/the-lighthouse-ledger/" /> on both, because rel_canonical() uses get_permalink(), so search engines index one page. A site that prefers a single address redirects instead: on template_redirect, when lp_isbn is set and is_singular( 'lp_book' ), a wp_safe_redirect( get_permalink(), 301 ) and exit.

The priority of guard_reviews() matters because of the 404 guessing. At priority 5 (before redirect_canonical()), the guard marked /hello-world/reviews/ and /books/the-lighthouse-ledger/reviews/extra/ as 404, and redirect_canonical() then "fixed" both 404s with a 301 to /hello-world/ and to the book. At priority 11 the canonical check has already passed the valid single post, and the guard’s 404 stands. Code that must keep a 404 either runs after priority 10 or filters redirect_canonical (return false to cancel one redirect); do_redirect_guess_404_permalink (5.5) disables the guessing for the whole site, and strict_redirect_guess_404_permalink (5.5) makes it require an exact slug.

Flushing correctly

New rules exist only in memory until a flush writes them to the rewrite_rules option. Deploying the Rewrites class to the test site as an in-place update (no reactivation) showed the usual symptom, a 404 on the new URLs while the old ones keep working, and wp rewrite list confirmed that no stored rule matched:

$ curl -s -o /dev/null -w '%{http_code}\n' https://lumenpress.test/books/isbn/978-1-23456-789-7/
404
$ curl -s -o /dev/null -w '%{http_code}\n' https://lumenpress.test/books/the-lighthouse-ledger/reviews/
404
$ wp rewrite list --match=/books/isbn/978-1-23456-789-7/ --fields=match,source --format=table
match                     source
books/[^/]+/([^/]+)/?$    lp_book
(.?.+?)(?:/([0-9]+))?/?$  page
$ wp rewrite flush
Success: Rewrite rules flushed.
$ curl -s -o /dev/null -w '%{http_code}\n' https://lumenpress.test/books/isbn/978-1-23456-789-7/
200
$ curl -s -o /dev/null -w '%{http_code}\n' https://lumenpress.test/books/isbn/978-1-99999-999-9/
404
$ curl -s -o /dev/null -w '%{http_code}\n' https://lumenpress.test/books/the-lighthouse-ledger/reviews/
200

The rules for when to flush:

  • On activation and deactivation, after registering (or unregistering) everything that adds rules, as Installer does. On Multisite each site has its own rewrite_rules option; a network activation flushes only the current site, and wp rewrite flush --url=<site> covers the others.

  • Once after an update that changes rules, from the versioned upgrade routine of Plugin Fundamentals. A new route ships with a bumped schema version; the routine calls flush_rewrite_rules( false ) on the first request after the update. Since 6.4 a flush called before wp_loaded is deferred to that hook, so routines that run on plugins_loaded are safe.

  • Never on every request. A flush on init regenerates every rule and writes the option on each page load, and with the default $hard = true also rewrites .htaccess. Under concurrent requests it can store a partial list.

  • flush_rewrite_rules( false ) (soft) updates only the option; the plugin’s routes need nothing in .htaccess, since WordPress’s block already sends every unknown path to index.php. A hard flush matters only for external rules and for repairing a missing .htaccess block. The flush_rewrite_rules_hard filter can veto hard flushes site-wide.

  • Loading Settings > Permalinks also flushes (hard) on every visit, without saving: the classic manual fix, and the reason a visit to that screen "repairs" 404s after a deployment.

From WP-CLI, wp rewrite flush --hard updates the option but skips .htaccess with the warning "Regenerating a .htaccess file requires special configuration", because the command line cannot inspect Apache. With apache_modules listing mod_rewrite in a wp-cli.yml in the WordPress root, the same command wrote the standard # BEGIN WordPress block on the test site; the commands are shown in Settings Screens.

Debugging rules with WP-CLI

wp rewrite list prints the stored rules in matching order; --match=<url> keeps only the rules that match a URL (the first line is the one WordPress uses), --source=<source> filters by origin (post, page, a post type or taxonomy name, a permastruct name, or other for add_rewrite_rule() and core extras), and --format=count gives the total (130 rules on the test site). wp rewrite structure changes the permalink structure (see Settings Screens).

wp rewrite list --match shows which rule matches, but not which variables survive the query_vars and request filters. A small script run with wp eval-file reproduces parse_request() for a path and prints $wp->matched_rule, $wp->matched_query and the final query vars:

<?php
/**
 * Prints how WordPress parses a request path.
 * Usage: wp eval-file debug-request.php /books/isbn/978-1-23456-789-7/
 */

global $wp;

$_SERVER['REQUEST_URI'] = $args[0];
$wp->parse_request();

printf(
	"matched_rule:  %s\nmatched_query: %s\nquery_vars:    %s\n",
	$wp->matched_rule,
	$wp->matched_query,
	wp_json_encode( $wp->query_vars )
);
$ wp eval-file debug-request.php /books/isbn/978-1-23456-789-7/
matched_rule:  ^books/isbn/(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])/?$
matched_query: lp_isbn=978-1-23456-789-7
query_vars:    {"post_type":"lp_book","p":4,"lp_isbn":"978-1-23456-789-7"}

$ wp eval-file debug-request.php /books/the-lighthouse-ledger/reviews/
matched_rule:  books/([^/]+)/reviews(/(.*))?/?$
matched_query: lp_book=the-lighthouse-ledger&lp_reviews=
query_vars:    {"lp_book":"the-lighthouse-ledger","post_type":"lp_book","name":"the-lighthouse-ledger","lp_reviews":""}

$ wp eval-file debug-request.php /books/isbn/978-1-99999-999-9/
matched_rule:  ^books/isbn/(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])/?$
matched_query: lp_isbn=978-1-99999-999-9
query_vars:    {"error":"404"}

The second output shows the empty-string endpoint variable, the third the request filter’s 404. A rule that matches in --match but yields no variable here means a missing query_vars entry.

Common errors and pitfalls

Symptom Cause and fix

New URL returns 404, old ones work

Rules not flushed. Flush once (wp rewrite flush, or the plugin’s activation or upgrade routine); confirm with wp rewrite list --match.

Every page except the home page returns 404 after a migration

The .htaccess block is missing or AllowOverride is off on Apache, or try_files is missing on nginx. Not a rewrite-rule problem: see Web Servers: Apache and nginx.

The route matches another rule (attachment, page, another post type)

Rule order. Use 'top' for add_rewrite_rule(), register permastructs and taxonomies before the post types they nest under, and check the first line of wp rewrite list --match.

The rule matches but get_query_var() returns nothing

The variable is not public. Add it with the query_vars filter, or use add_rewrite_tag() without a third argument.

An add_rewrite_rule() to a file other than index.php does nothing

External rules go only to .htaccess on a hard flush and never apply on nginx. Use a web-server rule or template_redirect.

What older books get wrong or leave out

  • Book and tutorial examples that call flush_rewrite_rules() in the same init callback that registers a post type flush on every request; flush on activation, deactivation and versioned upgrades only.

  • "Re-save the permalinks" still works as a manual fix (even loading the screen flushes), but a plugin that needs it is missing its activation flush.

  • Pretty permalinks do not require mod_rewrite in general: nginx and Caddy need one try_files-style line and no .htaccess, and all rules stay in PHP.

  • Custom routes, endpoints with their own query var (4.3), array queries (4.4), the 404-guessing filters (5.5) and plugin-registered block templates (6.7) are not covered by the older books.