Rewrite Rules & Permalinks

How a pretty URL is matched to query vars by WP_Rewrite, how to add a rule, and why forgetting to flush produces a 404.

Syntaxadd_rewrite_rule( string $regex, string|array $query, string $after = 'bottom' )

A pretty permalink like /2026/09/hello-world/ is not a file path. The web server's rules — an .htaccess block on Apache, a try_files directive on nginx — send every URL that does not match a real file to index.php. All routing after that happens in PHP.

Where the rules live

WP_Rewrite, exposed as the global $wp_rewrite, compiles a large array of regex-to-query-string pairs from your permalink structure plus every registered post type and taxonomy that asked for rewrites. That array is cached in the rewrite_rules option rather than rebuilt per request. On each request WP::parse_request() walks it in order, stops at the first regex that matches the path, and turns the paired query string into query vars.

Adding your own

add_rewrite_rule() pushes a pair onto that array. The query string always starts index.php? and uses $matches[n] placeholders for the regex capture groups; since WordPress 4.4 you may pass an array of query vars instead of a string. The third argument, 'top' or 'bottom' (the default), decides whether your rule is tested before or after core's. Core's structure rules are broad and will often swallow your URL if you go last.

Flushing

Because the array is cached, a new rule does nothing until it is regenerated. Call flush_rewrite_rules() once from register_activation_hook(), after the rule and any post types have been registered. Re-saving Settings > Permalinks does the same by hand — which is why "it 404s until I re-save permalinks" is the standard symptom.

Example

Example · php
<?php
/**
 * Plugin Name: Acme Docs Routing
 * Maps /docs/{version}/{slug}/ onto the acme_doc post type.
 */

add_action( 'init', 'acme_docs_rewrites' );
function acme_docs_rewrites() {
	// Single-quoted on purpose: "$matches[1]" in double quotes would be
	// interpolated by PHP before WordPress ever sees it.
	add_rewrite_rule(
		'^docs/([^/]+)/([^/]+)/?$',
		'index.php?post_type=acme_doc&name=$matches[2]&doc_version=$matches[1]',
		'top' // Core's structure rules are broad; go first or be swallowed.
	);
}

// A query var core does not already own must be declared, or parse_request
// drops it and the rule appears to half-work.
add_filter( 'query_vars', 'acme_docs_query_vars' );
function acme_docs_query_vars( $vars ) {
	$vars[] = 'doc_version';
	return $vars;
}

// The compiled rules live in the rewrite_rules option. Flush once, on
// activation, after the rules exist. Never on init: flush_rewrite_rules()
// rewrites .htaccess and re-saves the option every time it runs.
register_activation_hook( __FILE__, 'acme_docs_activate' );
function acme_docs_activate() {
	acme_docs_rewrites();   // register before flushing, or the flush misses it
	flush_rewrite_rules();
}

register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );

When to use it

  • Routing a URL such as /docs/6.8/install/ onto a custom post type
  • Adding a /invoice/ or /download/ suffix to existing page and post URLs
  • Debugging a custom URL that returns 404 on a live site but works locally
  • Understanding why re-saving Settings > Permalinks fixes a broken route

More examples

An endpoint appended to existing URLs

add_rewrite_endpoint( $name, $places, $query_var = true ) bolts a suffix onto a whole class of URLs instead of defining a standalone path, and registers the query var for you. Reach for it for per-object sub-views — /invoice/, /edit/, /download/ — where writing one regex per object would be absurd. It still needs a flush on activation.

Example · php
<?php
add_action(
	'init',
	static function () {
		// Adds /invoice/ (optionally /invoice/{value}/) to every page URL and
		// registers an 'invoice' query var in the same call.
		add_rewrite_endpoint( 'invoice', EP_PAGES );
	}
);

add_action(
	'template_redirect',
	static function () {
		// An endpoint hit with no value sets the var to an empty string, so
		// test with isset(), never with a truthiness check.
		if ( ! isset( $GLOBALS['wp_query']->query_vars['invoice'] ) ) {
			return;
		}

		if ( ! is_user_logged_in() ) {
			auth_redirect();
		}

		header( 'Content-Type: application/pdf' );
		// ... stream the file
		exit;
	}
);

Array form, and putting a rule at the bottom

Shows both the array form and what the $after argument actually decides. A specific route that must beat core's date and page rules goes 'top'; a broad fallback that should never shadow a real post goes 'bottom'.

Example · php
<?php
add_action(
	'init',
	static function () {
		// Since WordPress 4.4 the query may be an array of query vars instead
		// of a query string. No $matches placeholders are possible this way,
		// so use it for fixed routes only.
		add_rewrite_rule( '^status/?$', array( 'acme_route' => 'status' ), 'top' );

		// 'bottom' (the default) means core gets first refusal. That is what
		// you want for a catch-all fallback that should only fire when nothing
		// else matched.
		add_rewrite_rule( '^go/([^/]+)/?$', 'index.php?acme_shortlink=$matches[1]', 'bottom' );
	}
);

Seeing what actually matched

Splits the two failure modes apart. If matched_rule is empty the rule is not in the option and you need a flush; if it matched but query_vars is missing your key, the var is not registered on the query_vars filter.

Example · php
<?php
// Temporary mu-plugin for debugging a rule that "does nothing".
add_action(
	'parse_request',
	static function ( WP $wp ) {
		if ( ! isset( $_GET['acme_rules'] ) || ! current_user_can( 'manage_options' ) ) {
			return;
		}

		global $wp_rewrite;

		// The compiled array, rebuilt from the rewrite_rules option if empty.
		error_log( print_r( $wp_rewrite->wp_rewrite_rules(), true ) );

		// Empty matched_rule means no rule matched at all, which is a
		// different bug from a rule that matched and produced no posts.
		error_log( 'matched rule:  ' . $wp->matched_rule );
		error_log( 'matched query: ' . $wp->matched_query );
		error_log( 'query vars:    ' . wp_json_encode( $wp->query_vars ) );
	}
);

Discussion

  • Be the first to comment on this lesson.