On a WordPress site I maintain, I needed a single URL prefix — say /style/... — that could serve two content types: a custom post type first, and if that didn't exist, fall back to a regular post. The idea was simple in my head: register two rewrite rules with the same regex, one for the CPT and one for the post, and let WordPress try the first and fall through to the second.
The result: every CPT slug 404'd. Only the last-registered rule survived. No chaining, no fallback.
Root cause: rewrite rules are keyed by regex
Here's the part that trips people up. WordPress stores rewrite rules as an array keyed by the regex string. So if you register two rules with an identical regex but different query strings, the second one overwrites the first — exactly like $arr[$key] = $a; $arr[$key] = $b;. Only one rule per unique regex can live.
The code I assumed was correct:
// WRONG — the second rule overwrites the first, no fallthrough
add_rewrite_rule( '^style/([^/]+)/?$', 'index.php?post_type=article&name=$matches[1]', 'top' );
add_rewrite_rule( '^style/([^/]+)/?$', 'index.php?name=$matches[1]', 'top' ); // this one winsBecause both regexes are identical (^style/([^/]+)/?$), the second entry clobbers the first. All that survives is the post rule. Slugs that actually belong to the article CPT no longer have a matching rule → 404.
WordPress simply has no concept of "try this rule, and if it doesn't match try the next" for the same regex. Each regex may appear only once.
The fix: one rule + a private query var + a request filter
Since only one rule per regex can live, don't fight it — use a single rule, mark it with a private query var, then expand that marker in a request filter into a post_type array.
// 1. ONE rule, with its own marker query var
add_action( 'init', function () {
add_rewrite_rule(
'^style/([^/]+)/?$',
'index.php?name=$matches[1]&my_multitype=1',
'top'
);
} );
// 2. Register the marker query var so WP recognizes it
add_filter( 'query_vars', function ( $vars ) {
$vars[] = 'my_multitype';
return $vars;
} );
// 3. Expand the marker into a post_type array in the request filter
add_filter( 'request', function ( $qv ) {
if ( ! empty( $qv['my_multitype'] ) ) {
$qv['post_type'] = array( 'post', 'article' );
unset( $qv['my_multitype'] );
}
return $qv;
} );The key is step 3: with $qv['post_type'] = array( 'post', 'article' ), WordPress looks up the name slug across both content types in a single query — which is the real "fallback" we wanted all along, with no need for two rewrite rules. The my_multitype marker is unset so it doesn't leak into the final query.
Auto-flush rewrite rules after deploy
A new rewrite rule isn't active until it's flushed. Rather than manually re-saving permalink settings, I wire up a version-pinned auto-flush that runs on init after the rule is registered:
add_action( 'init', function () {
$version = '1.0.1'; // bump this whenever the rules change
if ( get_option( 'my_rewrite_version' ) !== $version ) {
flush_rewrite_rules();
update_option( 'my_rewrite_version', $version );
}
}, 99 ); // late priority, after add_rewrite_rule is registeredflush_rewrite_rules() is expensive, so never call it on every request. This version-pin pattern flushes once per deploy — when the version bumps — and stores it in wp_options.
Closing notes
- Rewrite rules are keyed by regex. Registering the same regex twice overwrites, it doesn't chain. Only one survives.
- Never expect fallthrough between two rules with the same regex. WordPress has no such mechanism.
- Use one rule + a private query var, then expand it in a
requestfilter. For multi-type, setpost_typeto an array — that's the real fallback. - Don't forget to
unsetthe marker query var. Left in place, it can mess up the final query. - Version-pin your auto-flush.
flush_rewrite_rules()is expensive; run it once per deploy via an option flag, not every request.
The core lesson: rewrite rules are a map keyed by regex, not an ordered list that's tried one by one. Once you think of it as a map, the solution is obvious — one key, one rule, and let the request filter handle the branching.
