A few weeks after a release, Google Search Console started filling up.
Duplicate without user-selected canonical — 68 URLs. Crawled – currently not indexed — 245. Discovered – not indexed — 62. And the pile grew, week over week.
Nothing was down. No errors in the logs. The site worked. But Google was crawling a growing set of URLs it had no business indexing, and I couldn’t see them in any report except the coverage one.
The reproduction
The URLs had one thing in common: they pointed at database records that were deleted or set to inactive. A portfolio item switched off. A record removed.
Every one of those URLs should have issued a 302 to its section listing. Instead:
$ curl -sI "https://site/portfolio/<inactive-id>"
HTTP/1.1 200 OK # expected: 302 to /portfolio
content-type: text/html
A full 200. Navigation, footer, styles — all there. And in the <head>:
<head></head>
No <title>, no meta description, no <link rel="canonical">. A page that renders, that Google can crawl, that looks like content — with nothing telling Google what it is or which URL owns it. That is exactly the recipe for “Duplicate without canonical.”
The code
Each section builds its page metadata from the database record. When the record doesn’t exist or is inactive, the builder returns false. A shared helper merged that into the page-level data:
$meta_tags = array_merge($data_page, get_meta_data($data_page));
function get_meta_data(array $data_page): array {
$record = find_post($data_page['post_id']);
return $record ? build_meta_data($record) : [];
}
if (!$meta_tags) { // the guard
redirect_to_listing();
}
Read the guard. if (!$meta_tags) — “if we didn’t get metadata, redirect.” Looks fine.
It never fires.
Why the guard can’t work
Walk it through with a deleted record:
-
find_post()returnsnull. -
get_meta_data()returns[]. -
array_merge($data_page, [])—$data_pageis a non-empty array (it always haspost_id,section, etc.). Merging a non-empty array with an empty one gives you back… a non-empty array. -
$meta_tagsis truthy.!$meta_tagsisfalse. The redirect is skipped.
The guard is asking “did I get an array?” when the question it needs to ask is “did I get the fields I need?”.
And it fails quietly a second time downstream. $meta_tags — with no title key — gets passed to the function that prints the <head>:
if ($title && $description && $url) {
// print <title>, meta description, canonical, OG tags…
}
$title is null, the if is false, the block is skipped, nothing is logged. The <head> comes out empty and the request finishes with a 200. No exception, no warning, no error-rate blip. The only system that noticed was Search Console, on its own crawl schedule, weeks later.
The fix
The title key only exists in $meta_tags when the module returned real data. So that’s what the guard should check:
- if (!$meta_tags) {
+ if (empty($meta_tags['title'])) {
redirect_to_listing();
}
Now a missing title means “invalid or inactive URL” and the 302 fires.
Two decisions worth calling out:
- Fixed in the 7 content controllers, not in the shared helper. The helper has other call sites that legitimately never guard, because their metadata builder can’t fail. Patching the helper would have forced a redirect path onto callers that don’t want one. The controller is the right containment point.
-
Same deploy: added 11
301s for legacy URL patterns that were returning404, and restored a review-schema field that had gone missing. If you’re touching redirects and the<head>, do the whole sweep once.
Verified against production
Right after deploy, with real HTTP requests:
- inactive item →
302to/portfolio(was200with an empty page), both URL variants - legacy portfolio URL →
302; legacy news URL →301to the canonical article - active control item →
200, no regression
The coverage buckets this fed — Duplicate without canonical, Crawled/Discovered – not indexed, 404 — recrawl on a ~2–4 week cycle, so the “after” numbers land later.
What I take from it
-
Guard on the value you need, not on “is there a value.”
if (!$result)andif (empty($result))feel equivalent until$resultis a shape that’s technically non-empty but missing the field you care about.array_merge+ a?: []fallback is a reliable way to manufacture exactly that shape. - SEO bugs are a class of bug that never throws. No 500, no error rate, no alert. Your monitoring is blind to them by construction. The report that catches them is Search Console’s Page Indexing / Coverage — and it runs on Google’s clock, so by the time you see the pile, it’s weeks old.
- A coverage report is a symptom list, not a cause list. Four buckets filling up at once was one truthiness bug.
The terse version of this, with the Search Console before/after: lionelpairuna.dev/ghost-pages-no-canonical. I do technical SEO and ship the fix in the codebase — lionelpairuna.dev. If your Search Console looks like the top of this post, a screenshot or one URL is enough to start.
