Lionel Pairuna
←  back

array_merge()  ·  hundreds of URLs

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.

Reproduced — portfolio item 97, inactive in production
$ 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) to if (empty($meta_tags['title'])). The title key 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 itemReviewed field 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
The coverage buckets this fed — Duplicate without canonical (68), Crawled / Discovered – not indexed (245 / 62), Not found 404 (47) — are on a recrawl cycle of roughly 2–4 weeks. Before-and-after screenshots available on request.

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

Contact

Something similar in your Search Console? Tell me what's stuck — a screenshot or a single URL is enough to start.

lionel@lionelpairuna.dev