WordPress Rewrite API Demystified: Custom URL Structures, Debugging, and Edge Cases
Every WordPress site relies on a translation layer that converts human-friendly URLs into query variables the system can understand. This translation layer is the Rewrite API, and it sits at the center of how WordPress routes incoming requests to the correct content. Most developers interact with it only when they change permalink settings or call flush_rewrite_rules() after registering a custom post type. But the Rewrite API does far more than handle basic permalinks. It provides a full system for defining custom URL patterns, injecting query variables, creating endpoints, and building URL structures that rival anything a framework router can produce.
This article breaks apart the internals of the WP_Rewrite class, examines the differences between add_rewrite_rule(), add_rewrite_endpoint(), and add_rewrite_tag(), explains endpoint masks, covers debugging techniques, and walks through real-world examples including event calendar URLs, multi-step form wizards, and API proxy patterns.
How WP_Rewrite Generates Rules from Permastructs
The WP_Rewrite class lives in wp-includes/class-wp-rewrite.php and is instantiated as the global $wp_rewrite object. When WordPress generates rewrite rules, it starts with “permastructs,” which are permalink structure definitions stored in $wp_rewrite->extra_permastructs. Each permastruct contains a URL pattern with rewrite tags like %postname%, %year%, or %category%, plus metadata about pagination, feeds, and endpoint support.
The method WP_Rewrite::generate_rewrite_rules() is the engine that transforms a permastruct into an array of regex-to-query-string pairs. Here is a simplified view of the process:
- WordPress splits the permalink structure string on the
/separator. - For each segment, it checks whether the segment is a rewrite tag (like
%postname%) or a static string (likeblog). - Rewrite tags get replaced with their corresponding regex patterns. The default tag
%postname%maps to the regex([^/]+), while%year%maps to([0-9]{4}). - The method builds progressively longer regex patterns. If the permalink structure is
/%category%/%postname%/, it first creates a rule matching just([^/]+)(category only), then([^/]+)/([^/]+)(category plus post name). - For each regex, it constructs a corresponding query string using
index.php?followed by the matched query variables, likeindex.php?category_name=$matches[1]&name=$matches[2]. - If the permastruct supports pagination, it appends additional rules for
/page/N/and/comment-page-N/. - If the permastruct supports feeds, rules for
/feed/rss2/and similar patterns get added.
The result is an associative array where each key is a regex and each value is the rewrite target. These arrays get merged into the master rewrite rules array that WordPress stores in the database as an option called rewrite_rules.
You can inspect this by calling:
global $wp_rewrite;
$rules = $wp_rewrite->wp_rewrite_rules();
// Or just get the option directly:
$rules = get_option( 'rewrite_rules' );
The wp_rewrite_rules() method triggers a full rule generation if rules have not yet been built during the current request. It calls WP_Rewrite::rewrite_rules(), which in turn calls generate_rewrite_rules() for every registered permastruct, then merges the results in a specific order: extra permastructs first, then post permastructs, then page rules, and finally root-level rules.
Order matters here. When WordPress receives a request, it iterates through the rewrite rules array from top to bottom and uses the first matching regex. If two rules could match the same URL, the one that appears earlier wins. This ordering behavior is the source of most rewrite conflicts.
The Permastruct Registration Path
When you register a custom post type with register_post_type() and set 'rewrite' => true, WordPress calls $wp_rewrite->add_permastruct() behind the scenes. The same happens for custom taxonomies via register_taxonomy(). Each call to add_permastruct() stores the structure definition in $wp_rewrite->extra_permastructs.
You can call add_permastruct() directly for completely custom URL structures:
add_action( 'init', function() {
global $wp_rewrite;
$wp_rewrite->add_permastruct( 'project', '/projects/%project%/', array(
'with_front' => false,
'paged' => true,
'feed' => false,
'ep_mask' => EP_NONE,
) );
} );
The with_front parameter controls whether the global permalink prefix (like /blog/) is prepended. Setting it to false keeps your URL clean. The ep_mask parameter determines which endpoint types (trackback, comment pages, feeds) attach to this structure.
add_rewrite_rule() vs add_rewrite_endpoint() vs add_rewrite_tag()
These three functions serve different purposes, and choosing the wrong one leads to confusion. Here is what each does and when to use it.
add_rewrite_rule()
This function inserts a single regex-to-query mapping into the rewrite rules array. It is the most direct way to add a custom URL pattern.
add_action( 'init', function() {
add_rewrite_rule(
'^events/([0-9]{4})/([0-9]{2})/?$',
'index.php?post_type=event&event_year=$matches[1]&event_month=$matches[2]',
'top'
);
} );
The third parameter is the position: 'top' or 'bottom'. Rules added at the top are checked before WordPress’s built-in rules, which means they take priority. Rules at the bottom only match if nothing else did first.
Critical detail: any query variables you reference in the rewrite target must be registered with WordPress. If event_year and event_month are custom query vars, you must register them using the query_vars filter:
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'event_year';
$vars[] = 'event_month';
return $vars;
} );
Without this step, WordPress will silently strip your custom variables from the query, and your template logic will receive nothing.
add_rewrite_tag()
A rewrite tag is a placeholder that you embed inside permalink structures. The built-in tags like %postname% and %year% are predefined. You use add_rewrite_tag() to create new placeholders that WordPress can recognize inside permastructs.
add_action( 'init', function() {
add_rewrite_tag( '%event_location%', '([^/]+)' );
} );
Once registered, you can use %event_location% inside a permastruct:
add_action( 'init', function() {
add_rewrite_tag( '%event_location%', '([^/]+)' );
global $wp_rewrite;
$wp_rewrite->add_permastruct( 'event', '/events/%event_location%/%event%/', array(
'with_front' => false,
) );
} );
The tag is also automatically registered as a query variable, so you do not need the query_vars filter for tags created this way. This is one advantage of add_rewrite_tag() over raw add_rewrite_rule().
add_rewrite_endpoint()
Endpoints are URL suffixes that WordPress appends to existing permalink structures. The most familiar built-in endpoint is /trackback/. When you add a custom endpoint, WordPress automatically generates rules that append your endpoint to every URL matched by the specified endpoint mask.
add_action( 'init', function() {
add_rewrite_endpoint( 'json', EP_PERMALINK | EP_PAGES );
} );
After flushing rewrite rules, this allows URLs like /my-post/json/ or /my-page/json/. The endpoint value becomes available via get_query_var( 'json' ). If the user visits /my-post/json/2/, the query var value will be "2". If they visit /my-post/json/ with no trailing value, the query var is set to an empty string (but it is set, which is different from not being set at all).
Endpoints are powerful because they piggyback on existing URL structures rather than requiring you to define full regex patterns. They are ideal for adding alternate output formats, sub-views, or additional pages within existing content.
EP_* Endpoint Masks: What They Control
Endpoint masks are bitmask constants that control which types of URLs an endpoint attaches to. When WordPress generates rewrite rules for a permastruct, it checks the permastruct’s ep_mask value against each registered endpoint’s mask using a bitwise AND. If the result is non-zero, the endpoint rules get generated for that permastruct.
The most commonly used masks:
EP_NONE(0) – No endpoints attach.EP_PERMALINK(1) – Single post permalinks.EP_ATTACHMENT(2) – Attachment URLs.EP_DATE(4) – Date-based archive URLs.EP_YEAR(8) – Year archives.EP_MONTH(16) – Month archives.EP_DAY(32) – Day archives.EP_ROOT(64) – The site root URL.EP_COMMENTS(128) – Comment-related URLs.EP_SEARCH(256) – Search result URLs.EP_CATEGORIES(512) – Category archive URLs.EP_TAGS(1024) – Tag archive URLs.EP_AUTHORS(2048) – Author archive URLs.EP_PAGES(4096) – Static page URLs.EP_ALL(8191) – All of the above combined.
You combine masks with the bitwise OR operator. For example, EP_PERMALINK | EP_PAGES means your endpoint will be available on both single post and page URLs but not on archives or search pages.
Custom post types get their own endpoint mask assigned during registration. The register_post_type() function sets the mask based on the 'rewrite' argument. If you need to control which endpoints attach to your custom post type, you can specify the ep_mask in the rewrite array:
register_post_type( 'portfolio', array(
'public' => true,
'rewrite' => array(
'slug' => 'work',
'ep_mask' => EP_PERMALINK,
),
) );
If you set ep_mask to EP_NONE, no endpoints will attach to your post type URLs. This is useful when you want to keep the URL space clean and prevent other plugins’ endpoints from affecting your post type.
Custom Endpoint Masks
You can define your own endpoint mask constants for entirely custom permastructs. Since the masks are powers of two, pick a value that does not collide with existing constants:
define( 'EP_EVENTS', 8192 ); // Next power of 2 after EP_ALL's components
add_action( 'init', function() {
add_rewrite_endpoint( 'attendees', EP_EVENTS );
// Your custom permastruct uses this mask
global $wp_rewrite;
$wp_rewrite->add_permastruct( 'event', '/events/%event%/', array(
'ep_mask' => EP_EVENTS,
) );
} );
Now the /attendees/ endpoint only attaches to event URLs, not to posts, pages, or anything else. This granular control prevents unintended route collisions.
Debugging: Viewing Registered Rewrite Rules and Testing Regex
Rewrite problems are among the most frustrating WordPress issues because the symptoms are vague: 404 errors, wrong templates loading, or query variables arriving empty. Here are concrete debugging strategies.
Dumping All Rewrite Rules
The simplest debugging step is to view the full set of active rules:
// Add this temporarily to a template or use WP-CLI
global $wp_rewrite;
$rules = $wp_rewrite->wp_rewrite_rules();
echo '<pre>';
foreach ( $rules as $regex => $query ) {
echo esc_html( $regex ) . ' => ' . esc_html( $query ) . "\n";
}
echo '</pre>';
Or use WP-CLI:
wp rewrite list --format=table
This shows every rule with its regex, query string, and source. The source column tells you whether a rule came from a post type, taxonomy, page, or a custom add_rewrite_rule() call. When troubleshooting, check this list to see if your rule exists at all.
Testing Which Rule Matches a URL
You can simulate URL matching by looping through rules:
function debug_url_match( $url_path ) {
global $wp_rewrite;
$rules = $wp_rewrite->wp_rewrite_rules();
// Strip leading slash
$url_path = ltrim( $url_path, '/' );
foreach ( $rules as $regex => $query ) {
if ( preg_match( '#^' . $regex . '#', $url_path, $matches ) ) {
return array(
'matched_rule' => $regex,
'query' => $query,
'matches' => $matches,
);
}
}
return false;
}
// Usage:
$result = debug_url_match( '/events/2024/03/' );
var_dump( $result );
This function mimics what WordPress does internally in WP::parse_request(). By running it against a URL that returns a 404, you can see whether any rule matches. If nothing matches, your rule is missing. If the wrong rule matches, you have an ordering problem.
Inspecting the Parsed Query
To see what query variables WordPress actually parsed from a URL, hook into the parse_request action:
add_action( 'parse_request', function( $wp ) {
if ( ! is_admin() ) {
error_log( 'Matched rule: ' . $wp->matched_rule );
error_log( 'Matched query: ' . $wp->matched_query );
error_log( 'Query vars: ' . print_r( $wp->query_vars, true ) );
}
} );
The $wp->matched_rule property contains the regex that won, $wp->matched_query contains the raw query string with $matches placeholders replaced, and $wp->query_vars contains the final parsed variables. This is the single most useful debugging hook for rewrite issues.
Using the Rewrite Rules Inspector Plugin
If you prefer a GUI approach, the “Rewrite Rules Inspector” plugin by Developer Automattic provides an admin page listing all rules with a test field. You paste a URL path, and it tells you which rule matched. For quick debugging sessions, this saves significant time compared to writing custom debug functions.
Common Pitfalls That Cause Silent Failures
Several issues cause rewrites to fail without obvious errors:
Missing query var registration. If you use a custom variable in your rewrite target but forget the query_vars filter, WordPress drops it during parsing. The request appears to work (no 404), but get_query_var() returns empty.
Stale rewrite rules. The rewrite rules array is cached in the rewrite_rules option. After adding new rules, you must flush. During development, visit Settings > Permalinks and click Save, or run wp rewrite flush. The rules will not update until you do.
Regex anchoring. WordPress wraps your regex in #^ and # when matching, meaning it anchors to the start but not the end unless you include $. A rule for events/([0-9]+) will also match events/123/extra/stuff. Always end your patterns with /?$ if you want exact matching.
Missing trailing slash handling. WordPress has internal redirect logic that adds or removes trailing slashes depending on the $wp_rewrite->use_trailing_slashes setting. If your custom rule does not account for the optional trailing slash, users might hit a redirect loop or a 404 depending on how they type the URL.
Conflict Resolution: When Plugins Register Overlapping Rules
Rewrite conflicts happen when two or more rules match the same URL pattern and WordPress picks the wrong one. Since WordPress uses first-match-wins processing, rule position determines the outcome.
Diagnosing Conflicts
Use the debug_url_match function from the previous section. If the matched rule is not the one you expected, look for competing rules with overlapping patterns. Common conflict scenarios include:
Custom post type slug matches a page slug. If you register a post type with the slug events and also have a WordPress page with the slug events, both generate rules that match /events/something/. The post type rules typically come first, so the page’s child pages become inaccessible.
Two plugins register similar top-level URL patterns. If Plugin A adds a rule for api/([^/]+)/?$ and Plugin B adds api/v2/([^/]+)/?$, the more general rule might match first and swallow requests intended for the more specific one.
Taxonomy and post type slug collision. Registering a taxonomy and post type with the same rewrite slug produces overlapping rules. WordPress will typically route requests to whichever structure generates its rules first.
Resolution Strategies
Reorder with the position parameter. The add_rewrite_rule() function accepts 'top' or 'bottom' as the third parameter. Specific rules should go to the top, and general catch-all rules should go to the bottom.
Make regex patterns more specific. Instead of ([^/]+), use patterns that only match what you expect. For numeric IDs, use ([0-9]+). For date components, use ([0-9]{4}). The tighter the regex, the less likely it overlaps with other rules.
Use unique prefixes. Give your URL structures a unique prefix segment. Instead of /api/ (which many plugins might use), use /myapp-api/ or a branded prefix.
Filter rules with the rewrite_rules_array hook. As a last resort, you can filter the entire rules array to reorder or remove conflicting rules:
add_filter( 'rewrite_rules_array', function( $rules ) {
$custom_rules = array();
$remaining = array();
foreach ( $rules as $regex => $query ) {
if ( strpos( $regex, 'my-custom-prefix' ) === 0 ) {
$custom_rules[ $regex ] = $query;
} else {
$remaining[ $regex ] = $query;
}
}
// Place custom rules at the very beginning
return array_merge( $custom_rules, $remaining );
} );
This hook fires after all rules are generated but before they are stored. It gives you full control over the final ordering.
Programmatic Flush Strategies That Survive Plugin Updates
Calling flush_rewrite_rules() is expensive. It regenerates every rule and writes them to the database. It should never run on every page load, and it should never run inside the init action on every request. The correct approach is to flush only when your rules actually change.
The Activation/Deactivation Pattern
The standard practice for plugins is to flush during activation and deactivation:
register_activation_hook( __FILE__, function() {
// Register post types and taxonomies first
my_plugin_register_post_types();
my_plugin_register_taxonomies();
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, function() {
flush_rewrite_rules();
} );
The activation hook registers your content types, then flushes so the new rules take effect. The deactivation hook flushes to remove your rules from the stored array.
Version-Based Flushing
When you update a plugin and change its rewrite structures, the activation hook does not fire. Users who update your plugin will have stale rules. The solution is to track a version number:
add_action( 'init', function() {
// Register post types and rules first
my_plugin_register_post_types();
my_plugin_add_rewrite_rules();
$stored_version = get_option( 'my_plugin_rewrite_version', '0' );
$current_version = '2.1'; // Bump this when rules change
if ( version_compare( $stored_version, $current_version, '<' ) ) {
flush_rewrite_rules();
update_option( 'my_plugin_rewrite_version', $current_version );
}
} );
This flushes exactly once after an update that changes rules, then stays quiet until the next version bump. The option value persists across requests, so the flush only happens on the first load after the update.
The after_switch_theme Hook
Themes that register custom post types or rewrite rules should flush during theme activation:
add_action( 'after_switch_theme', function() {
my_theme_register_post_types();
flush_rewrite_rules();
} );
This fires only once when the theme is activated, which is the appropriate time.
WP-CLI for Manual Flushes
During development, use WP-CLI instead of visiting the Permalinks page:
wp rewrite flush
wp rewrite flush --hard # Also regenerates .htaccess / web.config
The --hard flag updates the server configuration file, which matters if you use Apache with .htaccess rules.
Conditional Rewrites Based on Context
Sometimes you need URL routing that depends on runtime conditions: the logged-in state of the user, the time of day, or the presence of a specific cookie. The rewrite rules themselves are static (they are stored in the database), so true conditional routing requires a different approach.
Using the request Filter
The request filter fires after WordPress matches a rewrite rule and parses the query variables. You can modify the query vars before WordPress executes the query:
add_filter( 'request', function( $query_vars ) {
// Redirect non-logged-in users requesting dashboard content
if ( isset( $query_vars['post_type'] ) && 'dashboard_item' === $query_vars['post_type'] ) {
if ( ! is_user_logged_in() ) {
$query_vars = array( 'pagename' => 'login' );
}
}
return $query_vars;
} );
This modifies what content WordPress loads without changing the URL. The user still sees the original URL in their browser, but WordPress serves different content based on context.
Using template_redirect for Conditional Routing
If you need to perform redirects based on context after rewrite matching:
add_action( 'template_redirect', function() {
if ( get_query_var( 'event_rsvp' ) && ! is_user_logged_in() ) {
wp_redirect( wp_login_url( get_permalink() ) );
exit;
}
} );
Dynamic Rule Generation
For cases where different rules should exist for different site configurations, you can conditionally add rules during init:
add_action( 'init', function() {
if ( get_option( 'my_plugin_enable_api' ) ) {
add_rewrite_rule(
'^my-api/([^/]+)/?$',
'index.php?my_api_action=$matches[1]',
'top'
);
}
} );
The caveat is that changing the option value requires a rewrite flush. You should trigger a flush whenever the setting changes:
add_action( 'update_option_my_plugin_enable_api', function() {
flush_rewrite_rules();
} );
Performance Impact of Large Rewrite Rule Sets
A typical WordPress installation with a few plugins has several hundred rewrite rules. Each incoming request requires WordPress to iterate through these rules and run preg_match() against each one until it finds a hit. The performance impact depends on two factors: the total number of rules and the position of the matching rule in the array.
Benchmarking the Cost
Each preg_match() call is cheap in isolation, but hundreds of them add up. On a site with 500 rules where the match is the 400th rule, you are running 400 regex tests on every uncached request. In my testing, a set of 1,000 rules adds roughly 2-5ms to the request parsing phase on modern hardware. That number is small in absolute terms, but on high-traffic sites or sites with already slow response times, it is worth optimizing.
Reducing Rule Count
Disable unnecessary features in post type registration. By default, register_post_type() generates rules for feeds, pagination, and date archives. If your post type does not need feeds, set 'has_archive' => false and 'rewrite' => array( 'feeds' => false ):
register_post_type( 'testimonial', array(
'public' => true,
'has_archive' => false,
'rewrite' => array(
'slug' => 'testimonials',
'feeds' => false,
'pages' => false,
'with_front' => false,
),
) );
Each disabled feature eliminates several rules from the set.
Consolidate custom rules. If you have multiple add_rewrite_rule() calls that share a prefix, consider whether a single broader rule with more specific template logic could replace them. Five rules for api/users, api/posts, api/comments, api/tags, and api/categories could be one rule for api/([^/]+) with a switch statement in the handler.
Use verbose page rules cautiously. The $wp_rewrite->use_verbose_page_rules property, when true, creates an individual rewrite rule for every page on the site. On sites with thousands of pages, this explodes the rule count. Verbose page rules are typically enabled when the permalink structure starts with a static string followed by %postname%. Changing the permalink structure can avoid this.
Caching Considerations
The rewrite rules array is loaded from the rewrite_rules option on every request that is not served from a page cache. If you use an object cache (Redis, Memcached), the option is cached in memory, which speeds up loading. But if the rewrite_rules option is not in the autoload set (it is autoloaded by default), loading it requires an extra database query.
The most impactful performance optimization is full-page caching that bypasses WordPress entirely for cached requests. When a page cache serves the response, the rewrite rules are never consulted.
Building REST-like Pretty URLs for Custom Functionality
You can build clean, resource-oriented URL patterns using the Rewrite API without the REST API infrastructure. This is useful for custom frontends, form handlers, or lightweight API endpoints that do not need the overhead of WP_REST_Server.
Resource Collection and Single Resource Patterns
Consider building a custom directory with URLs like:
/directory/- list all entries/directory/healthcare/- entries in a category/directory/healthcare/acme-clinic/- single entry
add_action( 'init', function() {
// Register query vars
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'directory_category';
$vars[] = 'directory_entry';
$vars[] = 'directory_page_num';
return $vars;
} );
// Single entry
add_rewrite_rule(
'^directory/([^/]+)/([^/]+)/?$',
'index.php?directory_category=$matches[1]&directory_entry=$matches[2]',
'top'
);
// Category listing with pagination
add_rewrite_rule(
'^directory/([^/]+)/page/([0-9]+)/?$',
'index.php?directory_category=$matches[1]&directory_page_num=$matches[2]',
'top'
);
// Category listing
add_rewrite_rule(
'^directory/([^/]+)/?$',
'index.php?directory_category=$matches[1]',
'top'
);
// Main directory listing
add_rewrite_rule(
'^directory/?$',
'index.php?directory_category=all',
'top'
);
} );
Notice the ordering: more specific rules come first. The single entry rule (two path segments) is registered before the category listing rule (one path segment). If reversed, the category rule would match single entry URLs and capture only the first segment.
Loading Custom Templates for Rewrite Rules
After defining rules, you need WordPress to load the right template. Use the template_include filter:
add_filter( 'template_include', function( $template ) {
if ( get_query_var( 'directory_entry' ) ) {
$custom = locate_template( 'template-directory-single.php' );
if ( $custom ) return $custom;
}
if ( get_query_var( 'directory_category' ) ) {
$custom = locate_template( 'template-directory-archive.php' );
if ( $custom ) return $custom;
}
return $template;
} );
This pattern gives you full control over the template without needing to create actual WordPress pages or custom post types.
Generating URLs for Custom Rewrites
When you build custom rewrite rules, you lose the convenience of functions like get_permalink(). Build a helper function:
function get_directory_url( $category = '', $entry = '' ) {
$base = home_url( '/directory/' );
if ( $entry && $category ) {
return $base . urlencode( $category ) . '/' . urlencode( $entry ) . '/';
}
if ( $category ) {
return $base . urlencode( $category ) . '/';
}
return $base;
}
Always build URLs programmatically rather than hardcoding paths. This keeps your code portable across installations with different site URLs.
Complete Example: Event Calendar URLs
Here is a full implementation of a date-based event URL structure. The goal is URLs like:
/events/- all upcoming events/events/2024/- events in 2024/events/2024/03/- events in March 2024/events/2024/03/15/- events on March 15, 2024/events/2024/03/15/my-event-slug/- single event
class Event_Calendar_Rewrites {
public function __construct() {
add_action( 'init', array( $this, 'register_post_type' ) );
add_action( 'init', array( $this, 'add_rewrite_rules' ) );
add_filter( 'query_vars', array( $this, 'register_query_vars' ) );
add_filter( 'template_include', array( $this, 'load_templates' ) );
add_filter( 'post_type_link', array( $this, 'filter_permalink' ), 10, 2 );
}
public function register_post_type() {
register_post_type( 'event', array(
'public' => true,
'label' => 'Events',
'has_archive' => false, // We handle archives ourselves
'rewrite' => false, // We handle rewrites ourselves
'supports' => array( 'title', 'editor', 'thumbnail' ),
) );
}
public function add_rewrite_rules() {
// Single event: /events/2024/03/15/my-event/
add_rewrite_rule(
'^events/([0-9]{4})/([0-9]{2})/([0-9]{2})/([^/]+)/?$',
'index.php?post_type=event&name=$matches[4]&event_year=$matches[1]&event_month=$matches[2]&event_day=$matches[3]',
'top'
);
// Day archive: /events/2024/03/15/
add_rewrite_rule(
'^events/([0-9]{4})/([0-9]{2})/([0-9]{2})/?$',
'index.php?post_type=event&event_year=$matches[1]&event_month=$matches[2]&event_day=$matches[3]',
'top'
);
// Day archive paginated: /events/2024/03/15/page/2/
add_rewrite_rule(
'^events/([0-9]{4})/([0-9]{2})/([0-9]{2})/page/([0-9]+)/?$',
'index.php?post_type=event&event_year=$matches[1]&event_month=$matches[2]&event_day=$matches[3]&paged=$matches[4]',
'top'
);
// Month archive: /events/2024/03/
add_rewrite_rule(
'^events/([0-9]{4})/([0-9]{2})/?$',
'index.php?post_type=event&event_year=$matches[1]&event_month=$matches[2]',
'top'
);
// Month archive paginated
add_rewrite_rule(
'^events/([0-9]{4})/([0-9]{2})/page/([0-9]+)/?$',
'index.php?post_type=event&event_year=$matches[1]&event_month=$matches[2]&paged=$matches[3]',
'top'
);
// Year archive: /events/2024/
add_rewrite_rule(
'^events/([0-9]{4})/?$',
'index.php?post_type=event&event_year=$matches[1]',
'top'
);
// Year archive paginated
add_rewrite_rule(
'^events/([0-9]{4})/page/([0-9]+)/?$',
'index.php?post_type=event&event_year=$matches[1]&paged=$matches[2]',
'top'
);
// Main events page: /events/
add_rewrite_rule(
'^events/?$',
'index.php?post_type=event',
'top'
);
// Main events paginated
add_rewrite_rule(
'^events/page/([0-9]+)/?$',
'index.php?post_type=event&paged=$matches[1]',
'top'
);
}
public function register_query_vars( $vars ) {
$vars[] = 'event_year';
$vars[] = 'event_month';
$vars[] = 'event_day';
return $vars;
}
public function load_templates( $template ) {
if ( is_singular( 'event' ) ) {
$custom = locate_template( 'single-event.php' );
return $custom ? $custom : $template;
}
if ( get_query_var( 'post_type' ) === 'event' ) {
// Date-filtered archives
$custom = locate_template( 'archive-event.php' );
return $custom ? $custom : $template;
}
return $template;
}
public function filter_permalink( $post_link, $post ) {
if ( 'event' !== $post->post_type ) {
return $post_link;
}
// Use event_date meta or fall back to post_date
$event_date = get_post_meta( $post->ID, 'event_date', true );
if ( ! $event_date ) {
$event_date = $post->post_date;
}
$timestamp = strtotime( $event_date );
return home_url( sprintf(
'/events/%s/%s/%s/%s/',
date( 'Y', $timestamp ),
date( 'm', $timestamp ),
date( 'd', $timestamp ),
$post->post_name
) );
}
}
new Event_Calendar_Rewrites();
The key design decision here is setting 'rewrite' => false on the post type and handling all URL generation manually. This gives complete control over the URL structure but requires the post_type_link filter to generate correct permalinks for event posts.
In the archive template (archive-event.php), you would use the custom query vars to build a date-filtered query:
$args = array(
'post_type' => 'event',
'posts_per_page' => 12,
'paged' => max( 1, get_query_var( 'paged' ) ),
'meta_key' => 'event_date',
'orderby' => 'meta_value',
'order' => 'ASC',
);
$year = get_query_var( 'event_year' );
$month = get_query_var( 'event_month' );
$day = get_query_var( 'event_day' );
if ( $year ) {
$meta_query = array( 'relation' => 'AND' );
$start = sprintf( '%04d-01-01', intval( $year ) );
$end = sprintf( '%04d-12-31', intval( $year ) );
if ( $month ) {
$start = sprintf( '%04d-%02d-01', intval( $year ), intval( $month ) );
$end = date( 'Y-m-t', strtotime( $start ) );
}
if ( $day ) {
$start = sprintf( '%04d-%02d-%02d', intval( $year ), intval( $month ), intval( $day ) );
$end = $start;
}
$meta_query[] = array(
'key' => 'event_date',
'value' => array( $start . ' 00:00:00', $end . ' 23:59:59' ),
'compare' => 'BETWEEN',
'type' => 'DATETIME',
);
$args['meta_query'] = $meta_query;
}
$events = new WP_Query( $args );
Complete Example: Multi-Step Form Wizard
Multi-step forms need URLs that reflect progress through the wizard while maintaining a single logical endpoint. Target URL structure:
/apply/- step 1 (personal info)/apply/step/2/- step 2 (qualifications)/apply/step/3/- step 3 (review)/apply/confirmation/- confirmation page
add_action( 'init', function() {
add_rewrite_rule(
'^apply/confirmation/?$',
'index.php?pagename=apply&form_confirmation=1',
'top'
);
add_rewrite_rule(
'^apply/step/([0-9]+)/?$',
'index.php?pagename=apply&form_step=$matches[1]',
'top'
);
// Let the default page rule handle /apply/ as step 1
} );
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'form_step';
$vars[] = 'form_confirmation';
return $vars;
} );
In the page template for the "Apply" page:
$step = intval( get_query_var( 'form_step', 1 ) );
$is_confirmation = get_query_var( 'form_confirmation' );
if ( $is_confirmation ) {
get_template_part( 'template-parts/apply', 'confirmation' );
} else {
$step = max( 1, min( 3, $step ) ); // Clamp between 1 and 3
get_template_part( 'template-parts/apply', 'step-' . $step );
}
The form submission handler for each step validates the data, stores it in the session or a transient, then redirects to the next step URL:
if ( 'POST' === $_SERVER['REQUEST_METHOD'] && isset( $_POST['apply_step_nonce'] ) ) {
if ( ! wp_verify_nonce( $_POST['apply_step_nonce'], 'apply_step_' . $step ) ) {
wp_die( 'Security check failed.' );
}
// Validate and store step data...
$session_key = 'apply_data_' . wp_get_session_token();
$data = get_transient( $session_key ) ?: array();
$data[ 'step_' . $step ] = sanitize_post( $_POST, 'db' );
set_transient( $session_key, $data, HOUR_IN_SECONDS );
if ( $step < 3 ) {
wp_redirect( home_url( '/apply/step/' . ( $step + 1 ) . '/' ) );
exit;
} else {
// Process final submission
my_process_application( $data );
wp_redirect( home_url( '/apply/confirmation/' ) );
exit;
}
}
This approach works without JavaScript, degrades gracefully, and produces bookmarkable step URLs. Note that the step data is stored server-side using transients keyed to the session token, which prevents users from skipping ahead by manually changing the URL (the handler can check whether previous steps have data).
Complete Example: API Proxy Pattern
Sometimes you need WordPress to act as a proxy that receives requests at a clean URL and forwards them to external services. This is useful for hiding API keys from the browser, transforming responses, or centralizing third-party API access.
Target URL structure:
/proxy/weather/current/- fetch current weather/proxy/weather/forecast/5/- 5-day forecast/proxy/geocode/search-term/- geocoding lookup
add_action( 'init', function() {
add_rewrite_rule(
'^proxy/weather/forecast/([0-9]+)/?$',
'index.php?proxy_service=weather&proxy_action=forecast&proxy_param=$matches[1]',
'top'
);
add_rewrite_rule(
'^proxy/weather/([^/]+)/?$',
'index.php?proxy_service=weather&proxy_action=$matches[1]',
'top'
);
add_rewrite_rule(
'^proxy/geocode/([^/]+)/?$',
'index.php?proxy_service=geocode&proxy_action=$matches[1]',
'top'
);
} );
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'proxy_service';
$vars[] = 'proxy_action';
$vars[] = 'proxy_param';
return $vars;
} );
The proxy handler intercepts the request early using template_redirect and returns JSON directly:
add_action( 'template_redirect', function() {
$service = get_query_var( 'proxy_service' );
if ( ! $service ) {
return;
}
// Rate limiting
$ip = $_SERVER['REMOTE_ADDR'];
$rate_key = 'proxy_rate_' . md5( $ip );
$requests = intval( get_transient( $rate_key ) );
if ( $requests > 60 ) {
wp_send_json_error( array( 'message' => 'Rate limit exceeded' ), 429 );
}
set_transient( $rate_key, $requests + 1, MINUTE_IN_SECONDS );
$action = sanitize_text_field( get_query_var( 'proxy_action' ) );
$param = sanitize_text_field( get_query_var( 'proxy_param' ) );
$handler = Proxy_Handler_Factory::create( $service );
if ( ! $handler ) {
wp_send_json_error( array( 'message' => 'Unknown service' ), 404 );
}
$result = $handler->execute( $action, $param );
if ( is_wp_error( $result ) ) {
wp_send_json_error( array( 'message' => $result->get_error_message() ), 500 );
}
// Cache the response
$cache_key = 'proxy_' . md5( $service . $action . $param );
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
wp_send_json_success( $cached );
}
set_transient( $cache_key, $result, 5 * MINUTE_IN_SECONDS );
wp_send_json_success( $result );
} );
The handler class for weather might look like:
class Weather_Proxy_Handler {
private $api_key;
public function __construct() {
$this->api_key = defined( 'WEATHER_API_KEY' ) ? WEATHER_API_KEY : '';
}
public function execute( $action, $param = '' ) {
if ( empty( $this->api_key ) ) {
return new WP_Error( 'no_key', 'Weather API key not configured.' );
}
switch ( $action ) {
case 'current':
return $this->fetch_current();
case 'forecast':
$days = intval( $param ) ?: 5;
return $this->fetch_forecast( $days );
default:
return new WP_Error( 'unknown_action', 'Unknown weather action.' );
}
}
private function fetch_current() {
$response = wp_remote_get( 'https://api.weather.example/current', array(
'timeout' => 10,
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_key,
),
) );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
return new WP_Error( 'api_error', 'Weather API returned status ' . $code );
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
private function fetch_forecast( $days ) {
$response = wp_remote_get( 'https://api.weather.example/forecast?days=' . intval( $days ), array(
'timeout' => 10,
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_key,
),
) );
if ( is_wp_error( $response ) ) {
return $response;
}
return json_decode( wp_remote_retrieve_body( $response ), true );
}
}
This pattern keeps API keys server-side, adds rate limiting and caching, and presents clean URLs that front-end JavaScript can call. The URLs are human-readable and cacheable by CDNs if you add appropriate cache headers.
Advanced Pattern: Rewrite Rules with External Regex Libraries
The built-in regex matching in WordPress uses PCRE (Perl Compatible Regular Expressions) through PHP's preg_match(). While powerful, you might encounter situations where a single regex cannot express the match criteria you need. In these cases, combine a broad rewrite rule with narrow validation in PHP.
For example, suppose you want a URL pattern where a path segment must be a valid ISO 8601 date but the rewrite system only supports regex:
// Match broadly with regex
add_rewrite_rule(
'^schedule/([^/]+)/?$',
'index.php?schedule_date=$matches[1]',
'top'
);
// Validate precisely in PHP
add_action( 'template_redirect', function() {
$date_str = get_query_var( 'schedule_date' );
if ( ! $date_str ) return;
// Strict ISO 8601 validation
$dt = DateTime::createFromFormat( 'Y-m-d', $date_str );
if ( ! $dt || $dt->format( 'Y-m-d' ) !== $date_str ) {
global $wp_query;
$wp_query->set_404();
status_header( 404 );
return;
}
} );
This two-layer approach uses the rewrite rule for routing and PHP for validation. The rewrite rule captures anything that looks like a path segment, and the PHP code rejects invalid dates with a proper 404.
Rewrite Tags and Structured Permalink Generation
When you register a custom post type and want its permalink to include taxonomy terms or custom meta values, rewrite tags are the mechanism that makes this work.
Consider a job board where job listings have URLs like /jobs/engineering/senior-developer/. The first segment after /jobs/ is the department taxonomy, and the second is the post slug.
add_action( 'init', function() {
// Register the taxonomy first
register_taxonomy( 'department', 'job_listing', array(
'public' => true,
'hierarchical' => true,
'rewrite' => false, // We handle this ourselves
'show_admin_column' => true,
) );
// Register the rewrite tag
add_rewrite_tag( '%department%', '([^/]+)' );
// Register the post type with a custom permastruct
register_post_type( 'job_listing', array(
'public' => true,
'label' => 'Job Listings',
'has_archive' => 'jobs',
'rewrite' => array(
'slug' => 'jobs/%department%',
),
'supports' => array( 'title', 'editor' ),
) );
} );
// Filter the permalink to replace %department% with the actual term
add_filter( 'post_type_link', function( $post_link, $post ) {
if ( 'job_listing' !== $post->post_type ) {
return $post_link;
}
$terms = get_the_terms( $post->ID, 'department' );
if ( $terms && ! is_wp_error( $terms ) ) {
$term = array_shift( $terms );
$post_link = str_replace( '%department%', $term->slug, $post_link );
} else {
// Fallback for posts without a department
$post_link = str_replace( '%department%', 'uncategorized', $post_link );
}
return $post_link;
}, 10, 2 );
The critical step that developers often miss is the post_type_link filter. Without it, the generated permalink contains the literal string %department%, which produces broken URLs. The filter replaces the tag with the actual taxonomy term slug at runtime.
Handling Rewrite Rules in Multisite Installations
WordPress Multisite adds complexity to the rewrite system because each site in the network has its own set of rewrite rules. On a subdirectory installation (e.g., example.com/site2/), the rewrite rules for each subsite are prefixed with the site's path.
Key considerations for multisite:
Network-wide plugins must add rules on each site. A plugin activated network-wide has its init hooks fire on each site context, so add_rewrite_rule() calls automatically apply per-site. But flushing must happen per-site as well.
Programmatic flushing across sites:
function flush_rules_network_wide() {
if ( ! is_multisite() ) {
flush_rewrite_rules();
return;
}
$sites = get_sites( array( 'number' => 0 ) );
foreach ( $sites as $site ) {
switch_to_blog( $site->blog_id );
flush_rewrite_rules();
restore_current_blog();
}
}
Be cautious with this function on networks with hundreds of sites, as it triggers a full rule regeneration for each one.
Subdomain vs. subdirectory considerations. On subdomain installations (site2.example.com), each site has its own domain, and rewrites work independently. On subdirectory installations, the base site's .htaccess contains rules that route requests to the correct subsite before WordPress's internal rewrite rules are consulted.
Testing Rewrite Rules with WP-CLI
WP-CLI provides several commands that simplify rewrite debugging:
# List all rewrite rules
wp rewrite list --format=table
# Show rules matching a specific URL
wp rewrite list --match='events/2024/03'
# Flush rewrite rules
wp rewrite flush
# Show the current permalink structure
wp rewrite structure
# Test which rewrite rule matches a path
wp eval 'var_dump( url_to_postid( "/events/2024/03/" ) );'
The wp rewrite list --match flag is particularly useful. It tests a URL path against all registered rules and shows which one would match, along with the resulting query string.
For automated testing, you can write PHPUnit tests that verify rewrite rules:
class Test_Event_Rewrites extends WP_UnitTestCase {
public function test_single_event_url_matches() {
$this->set_permalink_structure( '/%postname%/' );
flush_rewrite_rules();
global $wp_rewrite;
$rules = $wp_rewrite->wp_rewrite_rules();
$url = 'events/2024/03/15/my-event';
$matched = false;
foreach ( $rules as $regex => $query ) {
if ( preg_match( '#^' . $regex . '#', $url, $matches ) ) {
$matched = true;
$this->assertStringContainsString( 'post_type=event', $query );
$this->assertStringContainsString( 'name=$matches[4]', $query );
break;
}
}
$this->assertTrue( $matched, 'Single event URL did not match any rewrite rule.' );
}
public function test_month_archive_url_matches() {
$this->set_permalink_structure( '/%postname%/' );
flush_rewrite_rules();
global $wp_rewrite;
$rules = $wp_rewrite->wp_rewrite_rules();
$url = 'events/2024/03';
$matched_query = '';
foreach ( $rules as $regex => $query ) {
if ( preg_match( '#^' . $regex . '#', $url ) ) {
$matched_query = $query;
break;
}
}
$this->assertStringContainsString( 'event_year=$matches[1]', $matched_query );
$this->assertStringContainsString( 'event_month=$matches[2]', $matched_query );
$this->assertStringNotContainsString( 'event_day', $matched_query );
}
}
Writing tests for rewrite rules is a practice that pays off quickly. Rewrite bugs are hard to trace in production, and automated tests catch regressions before deployment.
Non-Standard Uses of the Rewrite API
Beyond basic URL routing, the Rewrite API has some less obvious applications.
Virtual Pages Without Database Entries
You can create pages that exist only as rewrite rules, with no corresponding post in the database:
add_action( 'init', function() {
add_rewrite_rule( '^sitemap\.xml$', 'index.php?custom_sitemap=1', 'top' );
add_rewrite_rule( '^robots-custom\.txt$', 'index.php?custom_robots=1', 'top' );
} );
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'custom_sitemap';
$vars[] = 'custom_robots';
return $vars;
} );
add_action( 'template_redirect', function() {
if ( get_query_var( 'custom_sitemap' ) ) {
header( 'Content-Type: application/xml; charset=utf-8' );
echo generate_custom_sitemap();
exit;
}
if ( get_query_var( 'custom_robots' ) ) {
header( 'Content-Type: text/plain; charset=utf-8' );
echo generate_custom_robots();
exit;
}
} );
This pattern is used by SEO plugins, caching plugins, and any functionality that needs to serve non-HTML responses at specific URLs.
Short URLs and Vanity URLs
You can build a URL shortener directly into WordPress:
add_action( 'init', function() {
add_rewrite_rule( '^go/([a-zA-Z0-9]+)/?$', 'index.php?short_code=$matches[1]', 'top' );
} );
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'short_code';
return $vars;
} );
add_action( 'template_redirect', function() {
$code = get_query_var( 'short_code' );
if ( ! $code ) return;
$target = get_option( 'short_url_' . sanitize_key( $code ) );
if ( $target ) {
wp_redirect( esc_url( $target ), 301 );
exit;
}
// Short code not found
global $wp_query;
$wp_query->set_404();
status_header( 404 );
} );
URLs like /go/abc123/ can redirect anywhere. The mapping is stored in individual options, though for production use with many short URLs, a custom database table would be more efficient.
Language Prefix Routing
For multilingual sites that use URL prefixes like /en/, /fr/, /de/:
add_action( 'init', function() {
$languages = array( 'en', 'fr', 'de', 'es' );
$lang_pattern = '(' . implode( '|', $languages ) . ')';
// Prefix for all post types
add_rewrite_rule(
'^' . $lang_pattern . '/(.+?)/?$',
'index.php?lang=$matches[1]&pagename=$matches[2]',
'top'
);
// Root with language prefix
add_rewrite_rule(
'^' . $lang_pattern . '/?$',
'index.php?lang=$matches[1]',
'top'
);
} );
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'lang';
return $vars;
} );
This is a simplified version of what plugins like WPML and Polylang do internally. The language query var can then drive content filtering through the pre_get_posts action.
Handling Trailing Slashes and Canonical Redirects
WordPress has a built-in canonical redirect system in redirect_canonical() (found in wp-includes/canonical.php). This function detects when a URL does not exactly match the expected format and issues a 301 redirect. One of the things it handles is trailing slash enforcement.
If $wp_rewrite->use_trailing_slashes is true (the default when the permalink structure ends with /), WordPress redirects /events/2024/03 to /events/2024/03/. If it is false, the opposite redirect happens.
For custom rewrite rules, always include the optional trailing slash in your regex: /?$. This allows both formats to match, and redirect_canonical() handles the normalization.
If you need to disable canonical redirects for specific URLs (because they interfere with your custom routing), filter them out:
add_filter( 'redirect_canonical', function( $redirect_url, $requested_url ) {
// Don't redirect proxy URLs
if ( get_query_var( 'proxy_service' ) ) {
return false;
}
return $redirect_url;
}, 10, 2 );
Returning false from this filter cancels the redirect entirely for that request.
The Internal Rule Processing Pipeline
To fully understand the Rewrite API, it helps to trace a request through the entire pipeline. Here is what happens when a user visits https://example.com/events/2024/03/15/my-event/:
- Web server receives the request. Apache's
.htaccess(or Nginx's config) checks if the requested file exists. Since/events/2024/03/15/my-event/is not a real directory or file, the request is internally rewritten toindex.php. - WordPress boots.
index.phploadswp-blog-header.php, which loads the WordPress environment and then callswp(). - The
wp()function calls$wp->main(). This triggersWP::parse_request(). parse_request()loads rewrite rules. It calls$wp_rewrite->wp_rewrite_rules()to get the rules from the database option.- The request URL is extracted. WordPress strips the home URL base to get the request path:
events/2024/03/15/my-event. - Rule matching begins. WordPress iterates through the rules array, running
preg_match()on each regex against the request path. - A match is found. The regex
^events/([0-9]{4})/([0-9]{2})/([0-9]{2})/([^/]+)/?$matches. The matched groups are stored. - Query string substitution. The
$matchesplaceholders in the query string are replaced with actual matched values.index.php?post_type=event&name=my-event&event_year=2024&event_month=03&event_day=15. - Query variable filtering. WordPress checks each variable against the registered public query vars. Unregistered vars are dropped.
- The
requestfilter fires. Plugins can modify the query vars at this point. WP::query_posts()runs. This creates the mainWP_Querywith the parsed variables.- Template loading. The template loader selects the appropriate template based on the query results.
Understanding this pipeline reveals why certain debugging approaches work. Hooking into parse_request lets you see the state after step 9. Hooking into pre_get_posts lets you modify the query at step 11. Using template_include lets you override template selection at step 12.
Rewrite Rules and the REST API
The WordPress REST API has its own rewrite rules that follow the same system. When you register a REST route with register_rest_route(), WordPress generates rewrite rules that map URLs under /wp-json/ to the REST API handler.
You can see these rules in the rewrite list:
^wp-json/?$ => index.php?rest_route=/
^wp-json/(.*)?$ => index.php?rest_route=/$matches[1]
These two rules capture all REST API requests and pass them to the rest_route query variable. The REST infrastructure then does its own routing using the registered routes.
If you change the REST API prefix (using the rest_url_prefix filter), the rewrite rules update accordingly. This is why changing the prefix requires a rewrite flush.
An important interaction: if a custom rewrite rule and a REST route could match the same URL pattern, the custom rule wins if it appears first (added at the 'top' position). This can accidentally shadow REST endpoints. Always prefix your custom rules uniquely to avoid this collision.
Practical Tips and Patterns
To wrap up, here are practical guidelines gathered from years of working with the WordPress Rewrite API.
Always register post types and taxonomies before adding dependent rules. The init action fires at priority 10 by default. Register content types at priority 10 and add custom rules at priority 11 or later if they depend on those types being registered.
Use descriptive query variable names. Prefix them with your plugin or theme slug: myplugin_action instead of action (which WordPress already uses internally). Colliding with built-in query vars causes subtle and maddening bugs.
Test with both trailing slash settings. In your development environment, test your rules with both slash settings to confirm they work regardless of the site's permalink configuration.
Document your rules. For any non-trivial set of rewrite rules, maintain a table in your code comments that lists each URL pattern, its purpose, and the query variables it sets. Future developers (including yourself in six months) will be grateful.
Be defensive about input. Even though rewrite regex provides some validation, always sanitize query variables in your PHP handlers. A regex like ([^/]+) matches far more strings than you might intend.
Avoid flushing in production request cycles. Every call to flush_rewrite_rules() regenerates the entire rules set and writes it to the database. On a busy site, this can cause database lock contention. Reserve flushing for activation hooks, version-based one-time operations, and admin settings pages.
Monitor rule count over time. As you install plugins and register post types, the rule count grows. Periodically check wp rewrite list | wc -l to see if it is getting out of hand. If you are above 1,000 rules, audit which plugins are contributing and whether all those rules are necessary.
The WordPress Rewrite API is one of the system's most powerful but least understood subsystems. It enables URL structures that match anything a modern web application needs, from clean REST-like resource paths to date-based archives to multi-step forms. The key to using it effectively is understanding the rule generation pipeline, the matching order, the query variable system, and the flush lifecycle. With that foundation, you can build URL structures that are clean for users, correct for search engines, and maintainable for the developers who come after you.
Rachel Torres
Senior WordPress developer and core contributor. Specializes in WordPress internals, performance optimization, and PHP best practices. Runs a WordPress consultancy in Austin, Texas.