The site URLs looked like this:
https://www.example.co.uk/england_locations/west-yorkshire/car-valeting-leeds/
I needed every URL to look like this instead:
https://www.example.co.uk/england-locations/west-yorkshire/car-valeting-leeds/
Sounds simple. It was not simple. What follows is the actual sequence of mistakes, dead ends and finally the clean fix, with the prompts and SSH commands I used along the way. If you ever need to do this on a WordPress site that's been around long enough to have its own opinions, save yourself the day I lost.
The setup
The site runs on Cloudways (DigitalOcean), WordPress with the Genesis theme + a Business Pro child theme, Beaver Builder for layouts, CPT UI for the custom post type definition, WP Rocket for caching, and Cloudflare in front. The custom post type england_locations is hierarchical: counties as top-level posts, towns as children. So west-yorkshire is the parent of car-valeting-leeds, which is what gives the URLs their slash structure.
The working assumption was that I'd flip a setting in CPT UI and be done in five minutes. That assumption cost the rest of the day.
Attempt 1: change the Custom Rewrite Slug in CPT UI
CPT UI has a field called "Custom Rewrite Slug" that, on paper, lets you decouple the URL path from the post type name. Set it to england-locations, save, flush permalinks, done. That was the plan.
What actually happened: every single URL on the site that used the new slug returned 404. Every URL that used the old underscore slug also returned 404. The site looked like 1,183 SEO pages had been deleted.
The reason, which took some digging to surface, is a quirk in how CPT UI generates rewrite rules when the rewrite slug differs from the post type name. Internally, when CPT UI registers the post type, WordPress builds rewrite rules from the slug. The generated rule looked like this:
england-locations/(.+?)(?:/([0-9]+))?/?$ => index.php?england-locations=$matches[1]
Spot the problem. The query string says ?england-locations= (with a hyphen). But WordPress's WP_Query is looking for ?england_locations= (with an underscore) because that's the actual post type name. CPT UI generated a rule that points to a query variable that doesn't exist. WordPress receives a request for a query var it doesn't recognise, strips it, and returns a 404.
I rolled back the Custom Rewrite Slug to blank. URLs went back to working with underscores. Time to think.
What I tried with Claude Code
The way I work with Claude Code on this kind of task is conversational and iterative. I don't write specs upfront. I give it SSH access, hand it the problem, and let it diagnose against the actual server state. The thinking is: the server already knows the truth, why guess?
First prompt was something like:
"SSH credentials are in ssh.txt. We need to change the CPT slug from england_locations to england-locations. About 1,183 posts affected. Don't break anything. We need to be able to revert if it goes wrong."
Claude wrote a plan: take a backup, change the CPT UI setting, flush rewrite rules, test, and if anything broke, restore. I agreed, ran it, and immediately hit the 404 cascade described above.
This is where having an AI in the loop pays for itself versus reading Stack Overflow threads. Instead of hypothesising about why CPT UI's rewrite was broken, Claude SSH'd into the box and dumped the actual rewrite rules stored in wp_options:
ssh master@server "cd /home/master/applications/.../public_html && \
wp eval 'foreach(get_option(\"rewrite_rules\") as \$k=>\$v) { \
if(strpos(\$k,\"england\")!==false) echo \$k .\" => \". \$v .\"\n\"; }'"
The output made the bug obvious. Every rule that should have pointed to ?england_locations= was pointing to ?england-locations=. That's the root cause in one query.
Attempt 2: override the broken rules with add_rewrite_rule()
The natural fix is to register correct rules at higher priority. WordPress lets you do this with add_rewrite_rule(pattern, query, 'top'). The 'top' flag pushes the rule to the front of the array so it matches before any later rules.
I added a Woody Snippets snippet:
add_action( 'init', function () {
add_rewrite_rule(
'england-locations/(.+?)(?:/([0-9]+))?/?$',
'index.php?england_locations=$matches[1]&page=$matches[2]',
'top'
);
add_rewrite_rule(
'england-locations/?$',
'index.php?post_type=england_locations',
'top'
);
}, 20 );
Flushed permalinks. Tested. The archive at /england-locations/ worked. The single post URLs still 404'd.
This was confusing, because both rules looked identical in structure. So back to SSH, dump rewrite rules again. Both my rules were there. But the single-post pattern england-locations/(.+?)... also existed twice in the stored array, and CPT UI's broken version was the one being matched.
What I'd missed: add_rewrite_rule with 'top' doesn't override a rule with an identical pattern. It adds yours before, but when CPT UI's permastruct generator runs (which it does on every flush, after the init hook), it overwrites the array merge with its own version. The flush order matters and I was losing it.
I'd been at this for a couple of hours. The plan was getting longer, the snippet was getting more complicated, and the fix was getting further away. This is a pattern. When the workaround is fighting the framework's own behaviour, you've usually got the wrong angle.
Attempt 3: translate the query var at parse_request
The next approach was to stop fighting CPT UI and accept that it would generate ?england-locations= rules. I just needed to translate that to ?england_locations= before WordPress tried to find the post.
WordPress has a hook called parse_request that fires after the URL is matched against rewrite rules and before WP_Query runs. I could register england-locations as a valid query variable (so WordPress doesn't strip it), then hook into parse_request to copy its value into england_locations.
add_filter( 'query_vars', function ( $vars ) {
$vars[] = 'england-locations';
return $vars;
} );
add_action( 'parse_request', function ( $wp ) {
if ( ! empty( $wp->query_vars['england-locations'] ) ) {
$wp->query_vars['england_locations'] = $wp->query_vars['england-locations'];
unset( $wp->query_vars['england-locations'] );
}
} );
Tested. Still 404.
Now this should have worked. The code path looks correct. So I asked Claude to dump the actual query vars WordPress was building for the failing URL, using wp eval:
wp eval 'new WP(); $_SERVER["REQUEST_URI"] = "/england-locations/west-yorkshire/car-valeting-leeds/"; $wp = $GLOBALS["wp"]; $wp->parse_request(); print_r($wp->query_vars);'
What came back was strange. The query vars included the translated england_locations value, but also post_type=england-locations (with a hyphen, wrong) and a name field set to the full path. Multiple bits of state were getting set somewhere in the request lifecycle and they weren't all going through the hook.
At this point I'd spent half a day on this and the fixes were getting clever in a way that should have been a warning sign.
The fix that actually worked: rewrite_rules_array filter
The clean solution turned out to be much simpler than any of the previous attempts. WordPress has a filter called rewrite_rules_array that fires after all rules are generated, just before they're saved to wp_options. You can rewrite the entire array in one pass.
add_filter( 'rewrite_rules_array', function ( $rules ) {
$fixed = [];
foreach ( $rules as $pattern => $query ) {
if ( strpos( $query, 'england-locations=' ) !== false ) {
$query = str_replace( 'england-locations=', 'england_locations=', $query );
}
if ( strpos( $query, 'post_type=england-locations' ) !== false ) {
$query = str_replace( 'post_type=england-locations', 'post_type=england_locations', $query );
}
$fixed[$pattern] = $query;
}
return $fixed;
} );
This doesn't fight CPT UI. It lets CPT UI generate whatever it wants, then sweeps the array and fixes any rule whose query string points to the wrong (hyphen) query var. Run a wp rewrite flush and the corrected rules are persisted to the database. From that point on, WordPress sees the right query var, WP_Query finds the post, and the URL returns 200.
The lesson, which is generic enough to be worth tattooing: when WordPress generates something broken and you can't make it generate the correct version, intercept the output at the latest possible filter and rewrite it in place. Don't try to inject earlier in the chain. Don't try to make the broken thing reissue. Just fix the artifact.
Cloudflare made debugging harder than it needed to be
After the final fix, both URLs (with hyphen and underscore) initially still returned 404 from curl. I thought the fix hadn't worked. I dumped the rewrite rules again. They were correct. I ran wp_query directly with the right parameters in wp eval and it found the post. So the server was right, but the user-facing URL was still 404.
Cloudflare. The 404 responses from the broken state had been cached at the edge. Even after flushing WP Rocket and the WordPress object cache, Cloudflare was happily serving the stale 404. I confirmed by looking at the response headers:
cf-cache-status: DYNAMIC
age: 0
DYNAMIC means Cloudflare wasn't actually caching this URL. So why the 404? Because at the moment I ran that check, the cache had just expired or been bypassed, and the actual fix had reached me. The earlier 404s I'd been chasing were from cached responses to previous broken attempts.
If you're debugging routing changes behind Cloudflare, purge the cache at Cloudflare too, not just at WP Rocket. Better yet, test with a ?nocache=1 query string which Cloudflare passes through.
One more gotcha: the redirect rule was disabled
I had a Redirection plugin rule in place to 301 the old underscore URLs to the new hyphen URLs, so that anything still indexed by Google would land users in the right place instead of a 404. Once the hyphen URLs were working, the underscore URLs should have been redirecting cleanly.
They weren't. They were still 404'ing. I checked the Redirection plugin's database table:
wp db query "SELECT id,url,action_data,regex,status FROM SERVMASK_PREFIX_redirection_items \
WHERE action_data LIKE '%england-locations%';"
The rule existed: ID 718, regex ^/england_locations/(.*) targeting /england-locations/$1. Status: disabled.
Someone had created it months earlier and left it switched off. One SQL update to flip status to enabled, and the underscore URLs started returning 301 redirects to the hyphen version, which then returned 200.
The full chain looked like this after the fix:
england_locations/west-yorkshire/car-valeting-leeds/ -> 301 ->
england-locations/west-yorkshire/car-valeting-leeds/ -> 200
Exactly what the SEO expert wanted, finally.
What Claude Code was actually useful for
A few honest observations from using AI assistance for this kind of work.
It was good at running diagnostic queries against the live server. Dumping rewrite rules, querying the redirection table, simulating WP_Query against the failing URL, checking PHP syntax before deploying. I could ask "what does WordPress actually see when this URL hits parse_request" and have a precise answer in 30 seconds instead of inferring from logs.
It was less good at predicting which approach would work. The first two attempts (add_rewrite_rule with top priority, then query_vars + parse_request translation) were both reasonable on paper. Both failed. The model couldn't have known they'd fail without actually testing them, and to its credit, when each one didn't work, it didn't double down. It moved to the next approach.
It was good at keeping the safety rails on. Every code change to functions.php was preceded by a timestamped backup. Every database change was a single update that could be reversed by flipping the value back. When I ran out of context and resumed the next day, the context was reconstructed from the live server state rather than guesses about what we'd done.
The pattern that worked best was: state the goal, share the credentials, ask for a plan, agree the plan, then let it execute with permission gates. The model proposes, I approve, it does. When something breaks, we look at the real evidence together and adjust.
The pattern that didn't work was: ask for "the right answer" upfront. The right answer was the third thing I tried, and it only became obvious after the first two had failed and I'd dumped enough state to see what was really happening.
What I'd do differently next time
Three things.
First, before changing CPT UI's Custom Rewrite Slug, dump the rewrite rules array to see what CPT UI generates by default vs with a custom slug. The 404 cascade would have been obvious from comparing the two outputs in advance.
Second, set up a Cloudways staging environment for routing changes. The whole sequence of failed attempts would have been less stressful on a staging clone, and Cloudflare wouldn't have been in the way.
Third, get Claude to walk the redirect chain end-to-end as the first diagnostic, not the last. A simple curl -IL showing the full HTTP exchange would have caught the disabled redirect rule on hour one instead of hour six.
The final piece of code, for anyone Googling this
If you ever need to alias a hierarchical custom post type from one URL slug to another in WordPress, the only thing you actually need is this filter in functions.php:
add_filter( 'rewrite_rules_array', function ( $rules ) {
$fixed = [];
foreach ( $rules as $pattern => $query ) {
if ( strpos( $query, 'OLD_SLUG=' ) !== false ) {
$query = str_replace( 'OLD_SLUG=', 'POST_TYPE_NAME=', $query );
}
if ( strpos( $query, 'post_type=OLD_SLUG' ) !== false ) {
$query = str_replace( 'post_type=OLD_SLUG', 'post_type=POST_TYPE_NAME', $query );
}
$fixed[$pattern] = $query;
}
return $fixed;
} );
Replace OLD_SLUG with whatever's broken (in my case england-locations) and POST_TYPE_NAME with the actual registered post type name (england_locations). Set CPT UI's Custom Rewrite Slug to your new URL path. Run wp rewrite flush. Clear WP Rocket. Clear Cloudflare. Done.
If you want the URLs to flow the other direction (old underscore URLs 301 redirect to the new hyphen URLs), add a regex rule in the Redirection plugin from ^/old_slug/(.*) to /new-slug/$1. Make sure it's enabled. Test with a fresh incognito tab so you're not getting cached responses.
The whole thing, end to end, is about ten lines of code and three admin clicks. Getting there took six hours.