<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lionel Pairuna</title>
    <description>The latest articles on DEV Community by Lionel Pairuna (@lionelpairuna).</description>
    <link>https://dev.to/lionelpairuna</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4116598%2F713bbce8-5e32-4928-98b9-78c0b1c98d72.webp</url>
      <title>DEV Community: Lionel Pairuna</title>
      <link>https://dev.to/lionelpairuna</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lionelpairuna"/>
    <language>en</language>
    <item>
      <title>if (!$meta_tags) was never true — and Google noticed before we did</title>
      <dc:creator>Lionel Pairuna</dc:creator>
      <pubDate>Wed, 09 Sep 2026 01:58:31 +0000</pubDate>
      <link>https://dev.to/lionelpairuna/if-metatags-was-never-true-and-google-noticed-before-we-did-24nm</link>
      <guid>https://dev.to/lionelpairuna/if-metatags-was-never-true-and-google-noticed-before-we-did-24nm</guid>
      <description>&lt;p&gt;A few weeks after a release, Google Search Console started filling up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate without user-selected canonical&lt;/strong&gt; — 68 URLs. &lt;strong&gt;Crawled – currently not indexed&lt;/strong&gt; — 245. &lt;strong&gt;Discovered – not indexed&lt;/strong&gt; — 62. And the pile grew, week over week.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reproduction
&lt;/h2&gt;

&lt;p&gt;The URLs had one thing in common: they pointed at database records that were &lt;strong&gt;deleted or set to inactive&lt;/strong&gt;. A portfolio item switched off. A record removed.&lt;/p&gt;

&lt;p&gt;Every one of those URLs should have issued a &lt;code&gt;302&lt;/code&gt; to its section listing. Instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"https://site/portfolio/&amp;lt;inactive-id&amp;gt;"&lt;/span&gt;
HTTP/1.1 200 OK          &lt;span class="c"&gt;# expected: 302 to /portfolio&lt;/span&gt;
content-type: text/html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A full &lt;code&gt;200&lt;/code&gt;. Navigation, footer, styles — all there. And in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, no meta description, no &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;. 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."&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;Each section builds its page metadata from the database record. When the record doesn't exist or is inactive, the builder returns &lt;code&gt;false&lt;/code&gt;. A shared helper merged that into the page-level data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$meta_tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data_page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data_page&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data_page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data_page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'post_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;build_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$meta_tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;// the guard&lt;/span&gt;
    &lt;span class="nf"&gt;redirect_to_listing&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the guard. &lt;code&gt;if (!$meta_tags)&lt;/code&gt; — "if we didn't get metadata, redirect." Looks fine.&lt;/p&gt;

&lt;p&gt;It never fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the guard can't work
&lt;/h2&gt;

&lt;p&gt;Walk it through with a deleted record:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;find_post()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_meta_data()&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;array_merge($data_page, [])&lt;/code&gt; — &lt;code&gt;$data_page&lt;/code&gt; is a &lt;strong&gt;non-empty&lt;/strong&gt; array (it always has &lt;code&gt;post_id&lt;/code&gt;, &lt;code&gt;section&lt;/code&gt;, etc.). Merging a non-empty array with an empty one gives you back... a non-empty array.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$meta_tags&lt;/code&gt; is truthy. &lt;code&gt;!$meta_tags&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. The redirect is skipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The guard is asking &lt;em&gt;"did I get an array?"&lt;/em&gt; when the question it needs to ask is &lt;em&gt;"did I get the fields I need?"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And it fails &lt;strong&gt;quietly&lt;/strong&gt; a second time downstream. &lt;code&gt;$meta_tags&lt;/code&gt; — with no &lt;code&gt;title&lt;/code&gt; key — gets passed to the function that prints the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$title&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$description&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// print &amp;lt;title&amp;gt;, meta description, canonical, OG tags…&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$title&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, the &lt;code&gt;if&lt;/code&gt; is false, the block is skipped, &lt;strong&gt;nothing is logged&lt;/strong&gt;. The &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; comes out empty and the request finishes with a &lt;code&gt;200&lt;/code&gt;. No exception, no warning, no error-rate blip. The only system that noticed was Search Console, on its own crawl schedule, weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;title&lt;/code&gt; key only exists in &lt;code&gt;$meta_tags&lt;/code&gt; when the module returned real data. So that's what the guard should check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$meta_tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$meta_tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect_to_listing&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a missing title means "invalid or inactive URL" and the &lt;code&gt;302&lt;/code&gt; fires.&lt;/p&gt;

&lt;p&gt;Two decisions worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed in the 7 content controllers, not in the shared helper.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same deploy:&lt;/strong&gt; added 11 &lt;code&gt;301&lt;/code&gt;s for legacy URL patterns that were returning &lt;code&gt;404&lt;/code&gt;, and restored a review-schema field that had gone missing. If you're touching redirects and the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, do the whole sweep once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verified against production
&lt;/h2&gt;

&lt;p&gt;Right after deploy, with real HTTP requests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;inactive item → &lt;code&gt;302&lt;/code&gt; to &lt;code&gt;/portfolio&lt;/code&gt; (was &lt;code&gt;200&lt;/code&gt; with an empty page), both URL variants&lt;/li&gt;
&lt;li&gt;legacy portfolio URL → &lt;code&gt;302&lt;/code&gt;; legacy news URL → &lt;code&gt;301&lt;/code&gt; to the canonical article&lt;/li&gt;
&lt;li&gt;active control item → &lt;code&gt;200&lt;/code&gt;, no regression&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Guard on the value you need, not on "is there a value."&lt;/strong&gt; &lt;code&gt;if (!$result)&lt;/code&gt; and &lt;code&gt;if (empty($result))&lt;/code&gt; feel equivalent until &lt;code&gt;$result&lt;/code&gt; is a shape that's technically non-empty but missing the field you care about. &lt;code&gt;array_merge&lt;/code&gt; + a &lt;code&gt;?: []&lt;/code&gt; fallback is a reliable way to manufacture exactly that shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO bugs are a class of bug that never throws.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A coverage report is a symptom list, not a cause list.&lt;/strong&gt; Four buckets filling up at once was one truthiness bug.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;The terse version of this, with the Search Console before/after: &lt;a href="https://lionelpairuna.dev/ghost-pages-no-canonical.html" rel="noopener noreferrer"&gt;lionelpairuna.dev/ghost-pages-no-canonical&lt;/a&gt;. I do technical SEO and ship the fix in the codebase — &lt;a href="https://lionelpairuna.dev" rel="noopener noreferrer"&gt;lionelpairuna.dev&lt;/a&gt;. If your Search Console looks like the top of this post, a screenshot or one URL is enough to start.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>seo</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
