An SEO audit of this blog turned up a list of small, unglamorous findings. /blog/ returned 404. Category archive URLs contained a literal /./ segment — /blog/./wordpress-performance — while tag archives were clean. Author archives 404’d for every author. None of these looked dangerous. They looked like an afternoon.
That evening, every single post on the site 404’d. Twice. Two separate changes, about two hours apart, each written with a plausible explanation of why it couldn’t possibly affect post URLs, each taking down all of them.
TL;DR: Post permalinks here are /blog/%category%/%postname%…. Setting category_base to blog — the "obvious" fix for the /./ segment — made WordPress’ category rules and post rules share a prefix it could no longer tell apart, and every post 404’d. The replacement was three custom add_rewrite_rule() calls, one of them a ^blog/([^/]+)/?$ category rule explicitly reasoned to be unable to collide with two-segment post URLs. Every post 404’d again. Disabling all three restored the site; re-enabling them one at a time showed two were safe and the category rule was unnecessary — WordPress’ own permastruct already serves /blog/<category>. The mechanism of the second failure was never pinned down. The process that found it was what mattered.
Where Did the /./ Come From?
From category_base. Tag archives were clean (/blog/tags/<slug>) and use tag_base; category archives were broken and use category_base. Post permalinks were clean too. Only one option controls exactly the set of URLs that were broken, so a dot had ended up in it — most likely residue of the popular trick for hiding WordPress’ /category/ prefix, which produces exactly this kind of URL.
Diagnosing which option was the easy part. The mistake was in deciding what the option should be instead.
Why Did category_base = blog Break Every Post?
Because the post permalinks already start with blog/, and the category is part of the post permalink:
post: /blog/%category%/%postname%-%post_id%
category: /blog/<category> ← with category_base = "blog"
Code language: HTTP (http)
Setting the base to blog reads as "make category URLs look like the rest of the site". What it does is generate a second family of rewrite rules starting with the same literal segment as the first, competing for the same URL shapes. WordPress matches rewrite rules in order and takes the first hit. With both families rooted at blog/, WordPress could no longer resolve single-post URLs unambiguously, and every one of them stopped reaching the post query.
The fix shipped quickly: put category_base back to empty, and stop depending on WordPress’ rule generator for this URL at all. That’s where the second outage started.
Why Was the Replacement Supposed to Be Safe?
The replacement handled the three broken URL shapes explicitly, in a must-use plugin:
add_action( 'init', function () {
add_rewrite_rule( '^blog/?$', 'index.php', 'top' );
add_rewrite_rule( '^blog/author/([^/]+)/?$', 'index.php?author_name=$matches[1]', 'top' );
add_rewrite_rule( '^blog/([^/]+)/?$', 'index.php?category_name=$matches[1]', 'top' );
} );
Code language: PHP (php)
Plus a term_link filter to force category links to the clean URL in frontend output and in the sitemap. That detail is worth keeping regardless: WordPress’ core sitemap provider calls get_term_link() directly, so a category_link filter — the one most snippets use — never reaches it.
The commit message for the category rule made a specific argument: it is anchored to exactly one segment after blog/, post permalinks always have two, so the two can’t collide. That reasoning is clean and easy to agree with. It’s the kind of sentence that makes a reviewer stop looking.
Roughly two hours later, the commit log reads EMERGENCY: disable custom rewrite rules, they broke every post permalink. All three rules commented out, post URLs back immediately.
How Do You Find Which Rule Did It?
Not by building a better theory. The first theory had already been wrong once, and the second had been confidently wrong in a way no one could explain on the spot. The emergency commit said so explicitly: diagnose with real data instead of guesswork against production.
What followed took seven minutes and three commits:
- Re-enable only
^blog/?$. Deploy, check posts live. Fine. At the same time, check category archives with no custom rule: they already worked, because withcategory_baseempty and%category%leading the post permastruct, WordPress’ own generated rules already resolve/blog/<category>. The custom category rule had never been needed. - Add only the author rule. Deploy, check posts. Fine — and author archives now work, for every author.
- Don’t re-add the category rule at all. It was the only remaining candidate, and it solved a problem that no longer existed.
add_action( 'init', function () {
add_rewrite_rule( '^blog/?$', 'index.php', 'top' );
add_rewrite_rule( '^blog/author/([^/]+)/?$', 'index.php?author_name=$matches[1]', 'top' );
} );
Code language: PHP (php)
That last step is the uncomfortable part, and worth being honest about: the mechanism by which a one-segment rule broke two-segment URLs was never proven. By elimination the category rule is the culprit, and the elimination is solid. But the explanation for why it collided — rule ordering at top, an interaction with the permastruct’s own directory rules, or something in how the rules were flushed — stayed a hypothesis, because the rule could simply be deleted. Deleting it was the right call. Pretending to understand it would not have been.
What Else Broke That Same Day?
Two smaller surprises earlier the same afternoon set the tone, and both are worth knowing if you run a multisite:
- Must-use plugins are not loaded recursively. The SEO plugin first shipped as
mu-plugins/dar-seo-fixes/dar-seo-fixes.php. It deployed without error and did nothing:wp-settings.phponly scans the top level ofmu-plugins/. Regular plugins and themes support subdirectories; must-use plugins need a single flat file or a loader file at the top level. - Rewrite rules are cached per site on multisite. A plugin registering a virtual endpoint (
/llms.txt, here) produced correct content and still 404’d until the rules were flushed — on that specific site. The deploy now flushes rewrite rules for every site in the network after applying plugins and themes.
The second one is also why rewrite-rule changes deploy so effectively: once the deploy flushes rules everywhere, a bad rule doesn’t wait to be discovered by one cache miss. It’s live on every URL at once.
And about an hour after the last rewrite fix, the same evening produced eight posts that looked stuck and were only scheduled — which, in hindsight, was the same lesson again.
What’s the Generalizable Lesson Here?
- If your post permalinks contain
%category%behind a static prefix,category_basemust not equal that prefix. Two rule families rooted in the same literal segment compete, and the first match wins. - WordPress may already serve the URL you’re about to write a rule for. With
%category%in the post permastruct,/<front>/<category>resolves natively. Test the URL with no custom rule before adding one. - "This regex can’t collide with that URL shape" is a hypothesis about the whole rule table, not about the regex. Rewrite rules interact through order, priority and generated rules you didn’t write. Verify against the live table —
wp rewrite list --match=<url>shows which rule actually wins — not against the pattern alone. - When a change takes the site down, roll back all of it first, then reintroduce one piece at a time and check after each. It’s slower than reasoning only until the reasoning has been wrong once.
- You don’t have to understand a rule you can delete. But write down that you don’t — a fix explained by elimination should say so, so the next person doesn’t treat the hypothesis as fact.
- Filter
term_link, notcategory_link, when you need to change category URLs everywhere. Core’s sitemap provider bypassescategory_link. - Must-use plugins load from the top level of
mu-plugins/only, and on multisite, rewrite rules need flushing per site. Both fail silently.