D
P
0
← All articles Baca dalam Bahasa Indonesia

Technical SEO, Redirects & Migrations

Five Child Pages Return 404 and One Returns 200 With the Wrong Content: a CPT Rewrite Slug Swallowing an Entire Prefix

· · 7 min read
Five Child Pages Return 404 and One Returns 200 With the Wrong Content: a CPT Rewrite Slug Swallowing an Entire Prefix

The number that stopped me was not an error count. It was six.

Six pages set to published, all six listed neatly in page-sitemap.xml, all six handed to search engines for a long time, and not one of them actually opening. Nobody reported it. I happened to be reading through the sitemap of an ageing WordPress install for an unrelated reason, and on a whim I ran every URL in it and printed the status code:

xmllint --xpath '//*[local-name()="loc"]/text()' page-sitemap.xml \
  | while read -r url; do
      printf '%s  %s\n' "$(curl -sL -o /dev/null -w '%{http_code}' "$url")" "$url"
    done

Six rows under one shared prefix came back like this:

404  https://example.com/program/be-a-guest/
404  https://example.com/program/apply/
404  https://example.com/program/become-a-sponsor/
404  https://example.com/program/careers-zone/
404  https://example.com/program/video-archive-morning-talk/
200  https://example.com/program/video-archive-night-series/

Five hard 404s are bad. It was the last row that made me sit up straighter. I opened it in a browser and the page did look perfectly healthy, except the content was a completely different archive, not the child page that was supposed to live there. A 200 sitting on top of the wrong content is the kind of damage nobody ever files a ticket about, because from the outside nothing looks broken.

The pages exist, the resolution does not

My first and cheapest guess was that somebody had trashed those pages or moved their parent. In the admin everything was intact. Six published pages, correct parent, and the permalink field in the editor showing exactly the URL that had just handed me a 404.

PHP said the same thing:

$page = get_page_by_path('program/be-a-guest', OBJECT, 'page');
var_dump($page instanceof WP_Post, $page->post_status);
// bool(true)
// string(7) "publish"

The data is there, the status is publish, the path matches. So the content was not the broken part. The broken part was how that URL got translated into a query. And when URL translation is what breaks, there is exactly one place that stores the answer literally.

The rewrite table names the owner of the prefix

WordPress keeps its whole URL map in a single option. I printed every rule starting with that prefix:

foreach ((array) get_option('rewrite_rules') as $regex => $query) {
    if (strpos($regex, 'program') === 0) {
        echo $regex . '   =>   ' . $query . "\n";
    }
}

The first line explained everything:

program/([^/]+)/?$   =>   index.php?show_series=$matches[1]

The program prefix does not belong to that page any more. In the post type registration file, one custom post type was declared like this:

register_post_type('episode', [
    // ...
    'rewrite' => ['slug' => 'program'],
]);

That slug value is identical to the slug of a parent page that already existed on the site. From the moment that registration landed, every single-segment URL under /program/ stopped being a page lookup and became a taxonomy term lookup. The site only has two real terms, so every other child page under the same prefix resolved to a term that was never created, and WordPress returned a very confident 404.

No PHP error, no warning in the log, no notice in the admin. A prefix collision like this throws nothing at all. It quietly moves ownership of a branch of the URL space into someone else's hands.

That 200, and my own measuring tool

One thing still did not add up: why one URL answered 200 when its term did not exist either.

The answer was a flag I had typed myself. The audit command used -sL, and -L means follow the server wherever it sends you. I ran it again without following:

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  https://example.com/program/video-archive-night-series/
# 301 https://example.com/program/night-series/

That was not a healthy page, it was a guess. When a request ends in a 404, WordPress has handling that tries to guess the nearest slug through redirect_guess_404_permalink(), and this particular child page slug happened to be close enough to one of the two terms that do exist. Instead of admitting it found nothing, the site threw the visitor at a different archive, and the chain ended at 200. To a visitor and to a crawler, that page is alive and well. It just is not the page the sitemap promised.

The same -L habit charged me a second time a few hours later. After my first patch went in, another URL read 200 before the change and 200 after it, while behind that reading sat an old 301 that had been standing there long before I touched anything. For a few minutes I genuinely suspected my own edit. A baseline that follows redirects is not a baseline, it is a summary of where you ended up. Read the status line, not the last page that lands on screen.

The fix: a fallback, never the primary resolution

Two routes were out immediately. Changing the CPT rewrite slug would move every entry URL that has already been shared, which is a larger problem than the one I was fixing. Adding a new rewrite rule would mean adding another entry to the very table that had just been proven to be the collision site, plus a flush and a version gate to trigger it.

What was left was to touch resolution at the point where WordPress has already made its decision, right before the query runs, through the request filter. The filter is not the clever part. The ordering inside it is:

add_filter('request', function ($qv) {
    if (empty($qv['show_series']) || !is_string($qv['show_series'])) {
        return $qv;
    }
 
    // A real term still wins. This filter stays out of the way.
    if (get_term_by('slug', $qv['show_series'], 'show_series')) {
        return $qv;
    }
 
    // Only the leftovers get offered to a child page on the same path.
    $path = 'program/' . $qv['show_series'];
    $page = get_page_by_path($path, OBJECT, 'page');
 
    if ($page instanceof WP_Post && $page->post_status === 'publish') {
        unset($qv['show_series']);
        $qv['pagename'] = $path;
    }
 
    return $qv;
});

Three small details in there are not optional. Query vars can arrive as something other than a string, so is_string() is doing real work. $path is built before the unset(), because in the other order the value is already gone by the time you want it. And the post_status check keeps drafts from slipping out through this door.

What I like most about this shape is what is missing from it. No new rewrite rule, therefore no flush, no version option, and no manual trip to Settings to save permalinks after a deploy. The lookup happens per request, so a child page an editor creates tomorrow works the second it is published, with no code change at all.

Why the opposite direction needs a different tool

I once worked the mirror image of this on another site: removing the prefix so a CPT would be served at the root, /title/ instead of /prefix/title/. There the request filter was the wrong instrument, and the result quietly fell through to the blog index. The correct answer in that case was explicit rewrite rules.

Same filter, opposite outcome, and the difference comes down to one question: is anything else already resolving this URL correctly. In the root case nothing was, so the filter got promoted to primary resolution, and primary resolution cannot be handed to something that runs after the decision has been made. In this swallowed-prefix case, legitimate terms still resolve exactly as before and I am only picking up what falls through. A fallback is safe precisely because it is a fallback.

One prefix, one owner

A URL prefix can only have one owner. Before you give a CPT or a taxonomy a rewrite slug, check whether a page already lives at that path. A single throwaway get_page_by_path() call is enough, and it is far cheaper than discovering the answer months later through a sitemap. The collision never throws an error, it just relocates resolution without telling anyone.

The rest is about how you measure. Audit URLs by reading status codes one at a time, not by opening pages and feeling reassured, and do not let your tool follow redirects while you are hunting for a lie. Those six pages had been broken for an unknown length of time, and the only reason it stopped is that I read a sitemap for a completely different purpose.