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
The steps, in the order WP::main() runs them:
-
The web server sends the request to
index.phpbecause no file matches. On Apache the# BEGIN WordPressblock in.htaccessdoes it, on nginx atry_filesline (see Web Servers: Apache and nginx). Adding a rule never needs a server change. -
WP::parse_request()strips the home path, gets the rules from$wp_rewrite->wp_rewrite_rules()and tests them in order withpreg_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 matchparse_request()callsget_page_by_path()and skips the rule when no such page exists. -
Only public query vars survive:
$wp->public_query_varsfiltered byquery_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. Therequestfilter then sees the final array and may change it. -
WP::query_posts()runs the mainWP_Querywith those variables, andWP::handle_404()sends a 404 status when the query found nothing. -
template-loader.phpfirestemplate_redirect, where core’sredirect_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 |
|
|
2 |
|
none |
3 |
root, comments feed, search, author and date archives |
|
4 |
page rules and post rules (pages first when verbose page rules are on) |
|
5 |
|
none |
6 |
the whole list |
|
Source: Code Reference: WP_Rewrite
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 ) ) );
}
}
Source: Common APIs Handbook: Rewrite
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:
-
$regexis 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 whywp rewrite listshows both styles)./?$accepts the URL with or without the trailing slash; without the$,/books/isbn/978-1-23456-789-7/anythingwould match too. The capture group is what the route accepts: the ISBN pattern is the same one thelp_isbnmeta 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. -
$queryis a query string starting withindex.php?, with$matches[1],$matches[2]… for the groups. It must be written in single quotes: in double quotes PHP interpolates$matchesas an empty variable and the rule silently maps tolp_isbn=[1]. Since 4.4 it can also be an array, whichadd_rewrite_rule()turns into the same string. -
$afteris'bottom'by default. The Lumen Press rule needs'top': the post type’s attachment rulebooks/[^/]+/([^/]+)/?$matches any two segments afterbooks/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
Source: WP-CLI Commands: wp rewrite list
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, andget_query_var( 'lp_reviews' )returns''both when the endpoint is present and when it is absent. Test presence withisset( $wp_query->query_vars['lp_reviews'] ), as the class does. -
The
(/(.*))?group accepts anything after the endpoint:/reviews/extra/setslp_reviewstoextra. 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 |
|---|---|---|
|
301 to |
Trailing slash added by |
|
301 to |
Same rule; custom routes get it too |
|
301 to |
Plain query string of a post that has a pretty permalink |
|
301 to |
A 404 whose slug is the start of a published post’s slug: |
|
200 |
Custom route; |
|
200 |
Query vars that are not |
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
Installerdoes. On Multisite each site has its ownrewrite_rulesoption; a network activation flushes only the current site, andwp 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 beforewp_loadedis deferred to that hook, so routines that run onplugins_loadedare safe. -
Never on every request. A flush on
initregenerates every rule and writes the option on each page load, and with the default$hard = truealso 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 toindex.php. A hard flush matters only for external rules and for repairing a missing.htaccessblock. Theflush_rewrite_rules_hardfilter 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 )
);
Source: WP-CLI Commands: wp eval-file
$ 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 ( |
Every page except the home page returns 404 after a migration |
The |
The route matches another rule (attachment, page, another post type) |
Rule order. Use |
The rule matches but |
The variable is not public. Add it with the |
An |
External rules go only to |
What older books get wrong or leave out
-
Book and tutorial examples that call
flush_rewrite_rules()in the sameinitcallback 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_rewritein general: nginx and Caddy need onetry_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.