Ghost pages served with no canonical
A deleted or inactive record still returned a full HTTP 200 page
— with nothing in the <head>. Search Console
noticed before anyone else did.
$ curl -sI https://site/portfolio/<inactive-id> HTTP/1.1 200 OK ← expected: 302 to /portfolio content-type: text/html <head></head> ← no title, description or canonical
Context
After a code release, Google Search Console began accumulating URLs under Duplicate without user-selected canonical and Crawled – currently not indexed. The pile grew week over week.
The problem
Any URL pointing to a deleted or inactive database record — a
portfolio item switched to inactive, say — still returned a full
200 page: navigation, footer and styles all present, but an empty
<head>. No <title>, no meta
description, no <link rel="canonical">. Those URLs
should have issued a 302 to the section listing.
Root cause
Each section's get_header_code() builds the page metadata
from the database record and returns false when the record
doesn't exist or is inactive. A shared helper merged that result into
the page-level data:
$meta_tags = array_merge($data_page, get_meta_data($data_page));
function get_meta_data($data_page) {
$record = find_post($data_page['post_id']);
return $record ? build_meta_data($record) : [];
}
if (!$meta_tags) { // never true
redirect_to_listing();
}array_merge() of a non-empty array with [] is
still a non-empty array, so $meta_tags was always truthy
and the redirect guard never fired. Seo::gen_page_metas()
then received a $prms array with no title
key, and its internal
if ($title && $description && $url) check
failed without raising anything.
Evidence
Reproduced against a real record: portfolio item id 97,
active = 0 in both the local and the production database.
A direct request returned HTTP 200 with an empty
<head> instead of a redirect.
The fix
- Changed the guard in the 7 content controllers from
if (!$meta_tags)toif (empty($meta_tags['title'])). Thetitlekey only exists when the module returned real data, so a missing title now means “invalid or inactive URL → 302 to the listing.” - Applied at the controller level, not in the shared helper — it has other call sites that legitimately have no guard because their header never fails.
- Added 11 new 301 redirects for legacy URL patterns that were returning 404.
- Same deploy: restored a missing
itemReviewedfield in the homepage review schema.
Result
Verified against production immediately after deploy, with real HTTP requests:
- inactive item 97 → 302 to
/portfolio(was 200 with an empty page), both URL variants - legacy portfolio URL → 302; legacy news URL → 301 to the canonical article
- active control item → 200, no regression
- Review snippets (
itemReviewed): 4 invalid items → 0 — fix validated by Google
What this shows
- Reading a Search Console coverage report and separating the symptom buckets from a single root cause
- Tracing a systemic indexation problem to a language-level truthiness bug
- Fixing it surgically across a multilingual codebase, choosing the containment point deliberately
- Verifying with real HTTP requests against production