<?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: NessFlow</title>
    <description>The latest articles on DEV Community by NessFlow (@nessflow_8283f7335b896207).</description>
    <link>https://dev.to/nessflow_8283f7335b896207</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4086728%2Fe416d4d4-6cf9-484c-90c6-c595ddeacf65.png</url>
      <title>DEV Community: NessFlow</title>
      <link>https://dev.to/nessflow_8283f7335b896207</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nessflow_8283f7335b896207"/>
    <language>en</language>
    <item>
      <title>Googlebot Renders Your JavaScript. ChatGPT Doesn't.</title>
      <dc:creator>NessFlow</dc:creator>
      <pubDate>Tue, 15 Sep 2026 17:17:25 +0000</pubDate>
      <link>https://dev.to/nessflow_8283f7335b896207/googlebot-renders-your-javascript-chatgpt-doesnt-5e92</link>
      <guid>https://dev.to/nessflow_8283f7335b896207/googlebot-renders-your-javascript-chatgpt-doesnt-5e92</guid>
      <description>&lt;p&gt;A few weeks ago I &lt;a href="https://www.linkedin.com/posts/tarek-morgene_50-million-lines-of-server-logs-analyzed-activity-7499381775572520960-dPmX" rel="noopener noreferrer"&gt;posted a milestone on LinkedIn&lt;/a&gt;: &lt;a href="https://nessflow.com/en" rel="noopener noreferrer"&gt;NessFlow&lt;/a&gt; had crossed 50 million server log lines analyzed in private beta. Most of the replies weren't about the number. They were about one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sites that traditional crawlers fail to audit: WAFs, firewalls, adaptive rate limiting, 10k-SKU e-commerce catalogs, media.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Several people asked the same thing, in different words: &lt;em&gt;which sites, and what exactly fails?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is one run, and the finding at the end of it, which had nothing to do with crawling.&lt;/p&gt;




&lt;h2&gt;
  
  
  The site
&lt;/h2&gt;

&lt;p&gt;One of Carrefour's regional storefronts. Roughly &lt;strong&gt;30,000 crawlable pages&lt;/strong&gt;, on a &lt;strong&gt;PWA front end&lt;/strong&gt;: the initial HTML response is a shell, and product, category and facet content is assembled client-side.&lt;/p&gt;

&lt;p&gt;An SEO consultant had already tried to audit it with Screaming Frog. The crawl never produced a usable dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Could Screaming Frog have crawled it?
&lt;/h2&gt;

&lt;p&gt;I want to answer this honestly. The tempting version of this story, &lt;em&gt;"their tool failed, ours didn't"&lt;/em&gt;, is false, and any competent reader would take it apart in the comments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Yes. Screaming Frog could have crawled this site.&lt;/strong&gt; Let's be precise about why it didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale was never the problem.&lt;/strong&gt; 30,000 URLs is small. Screaming Frog's memory storage mode is &lt;a href="https://www.screamingfrog.co.uk/seo-spider/tutorials/how-to-crawl-large-websites/" rel="noopener noreferrer"&gt;recommended up to 500k URLs&lt;/a&gt;, handling a couple hundred thousand on 8GB of RAM; database storage mode goes into the millions. Anyone claiming 30k pages broke the tool is describing a misconfiguration, not a limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rendering wasn't the problem either.&lt;/strong&gt; The SEO Spider has Chromium-based JS rendering. It's expensive: you must keep resource crawling enabled, and Screaming Frog's own guidance notes most servers don't want to be crawled faster than about 5 URLs/second. So 30k rendered URLs is a multi-hour job on a laptop. Painful, not impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The URL space was a real problem, but a solvable one.&lt;/strong&gt; A grocery catalog with facets doesn't have 30,000 URLs, it has a combinatorial space. Screaming Frog handles this with exclude regex, &lt;em&gt;if you already know the facet patterns&lt;/em&gt;. On an unfamiliar site you don't, so the first crawl diverges, and you find out at hour six.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access was the actual blocker.&lt;/strong&gt; One desktop, one IP, one user agent, sustained concurrency, against adaptive rate limiting and WAF edge rules. And here's the part that matters: the fixes are mostly &lt;strong&gt;not client-side&lt;/strong&gt;. You can slow down and change your UA, but the reliable fix is having the client allowlist your IP at the edge, which is a permissions and relationship problem, not a software feature.&lt;/p&gt;

&lt;p&gt;So the honest framing isn't &lt;em&gt;"we crawl harder."&lt;/em&gt; It's: &lt;strong&gt;we start from the server side, where you don't need to be let in, because you're already inside.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The question a crawl doesn't answer
&lt;/h2&gt;

&lt;p&gt;Here's where technical audits usually stop and where the commercial work starts.&lt;/p&gt;

&lt;p&gt;Suppose you &lt;em&gt;do&lt;/em&gt; get a clean crawl of 30,000 pages. You now have 30,000 rows of status codes, titles, word counts and canonicals. Almost none of that is a business finding, because in grocery e-commerce those 30,000 pages are not equal in any way that matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search demand in grocery is brutally concentrated.&lt;/strong&gt; Nobody types &lt;em&gt;"plain yogurt 4×125g, brand X, reference 384920."&lt;/em&gt; They type &lt;em&gt;"grocery delivery [city]"&lt;/em&gt;, &lt;em&gt;"buy basmati rice online"&lt;/em&gt;, &lt;em&gt;"organic products cheap"&lt;/em&gt;. A 30,000-SKU catalog rarely carries more than a few hundred URLs with meaningful non-branded demand, and they're almost all &lt;strong&gt;category, subcategory and brand pages&lt;/strong&gt;, not product detail pages.&lt;/p&gt;

&lt;p&gt;That single fact reorganizes the audit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Crawl budget spent on PDPs is spent on pages that will never rank and rarely convert from organic.&lt;/strong&gt; They still need to be indexable for long-tail and structured data, but they aren't where the money is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facets are not uniformly bad.&lt;/strong&gt; Most combinations are index bloat and should be closed. A small subset (&lt;em&gt;organic&lt;/em&gt;, &lt;em&gt;gluten-free&lt;/em&gt;, &lt;em&gt;halal&lt;/em&gt;, brand-within-category) carries genuine standalone demand and deserves promotion to real landing pages with its own copy. Deciding &lt;em&gt;which&lt;/em&gt; is an SEO judgement call, not a crawl setting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SKU churn is the silent killer.&lt;/strong&gt; Grocery rotates constantly: seasonal lines, delisted references, permanent out-of-stock. Thousands of PDPs die every month. Whether they 404, 410, redirect to their category, or stay live with alternatives changes both your index footprint and your revenue, and most storefronts have never made that decision deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expired promotion pages accumulate.&lt;/strong&gt; Weekly flyers and campaign pages go stale, become orphans and soft 404s, and they're often the pages that earned the site's few real backlinks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a chain, store pages are frequently the strongest organic asset&lt;/strong&gt; and the most neglected: thin, templated, duplicated across hundreds of locations, competing with each other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the first job isn't "find broken pages." It's &lt;strong&gt;segment by template, then ask which templates carry demand.&lt;/strong&gt; Everything else is triage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your platform picked your no-JS ratio before you did
&lt;/h2&gt;

&lt;p&gt;Once you segment by template, a pattern shows up across projects that has nothing to do with any individual site. &lt;strong&gt;The commerce platform and theme you chose already determined how much of your catalog exists in the initial HTML.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every major platform now offers two paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Server-rendered path&lt;/th&gt;
&lt;th&gt;Headless / React path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Magento&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.hyva.io/" rel="noopener noreferrer"&gt;Hyvä&lt;/a&gt;: PHP templates, Tailwind, Alpine.js&lt;/td&gt;
&lt;td&gt;PWA Studio: React, Apollo, GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PrestaShop&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://warehouse.iqit-commerce.com/" rel="noopener noreferrer"&gt;Warehouse&lt;/a&gt;: Smarty templates&lt;/td&gt;
&lt;td&gt;custom headless front end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WooCommerce&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wpastra.com/" rel="noopener noreferrer"&gt;Astra&lt;/a&gt;: PHP/WordPress&lt;/td&gt;
&lt;td&gt;headless WP + Next.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://shopify.dev/changelog/online-store-2-0-json-templates-and-improvements-to-liquid" rel="noopener noreferrer"&gt;Online Store 2.0&lt;/a&gt;: Liquid + JSON templates, &lt;a href="https://github.com/Shopify/dawn" rel="noopener noreferrer"&gt;Dawn&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://hydrogen.shopify.dev/" rel="noopener noreferrer"&gt;Hydrogen&lt;/a&gt;: React&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BigCommerce&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://docs.bigcommerce.com/developer/docs/storefront/getting-started" rel="noopener noreferrer"&gt;Stencil&lt;/a&gt;: Handlebars&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.catalyst.dev/" rel="noopener noreferrer"&gt;Catalyst&lt;/a&gt;: Next.js, React, GraphQL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The left column ships fully-formed HTML. Hyvä is the clearest case: PHP composes the page on the server, Tailwind styles it inline, Alpine.js adds reactivity to specific components. Crawlers get the content and the structured data immediately, with no hydration gap. Warehouse, Astra, Liquid and Stencil work the same way for the same reason: they never stopped being server-rendered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right column is not "bad for SEO," and I'd push back on anyone who says so.&lt;/strong&gt; Hydrogen and Catalyst both server-render. The leak is narrower and far more specific:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The shell renders. Then facets, pagination, variant selection, stock status and price are fetched client-side after hydration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which means the page a non-rendering client sees is a &lt;strong&gt;category page with no products in it&lt;/strong&gt;. The template is fine. The catalog isn't there. And because the header, nav and footer render normally, a naive word count says the page has content, which is exactly why a raw word count is a useless test and a &lt;em&gt;ratio&lt;/em&gt; isn't.&lt;/p&gt;

&lt;p&gt;You can check any site in one command:&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="c"&gt;# words in the raw HTML&lt;/span&gt;
curl &lt;span class="nt"&gt;-sA&lt;/span&gt; &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; Googlebot/2.1)"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;[^&amp;gt;]*&amp;gt;/ /g'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that to the word count of the rendered DOM. If the first number is a small fraction of the second on your &lt;em&gt;category&lt;/em&gt; templates, the rest of this article is about you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two measurements
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The control probe
&lt;/h3&gt;

&lt;p&gt;Before crawling anything, NessFlow sends reachability probes to a handful of URLs with different identities, and one of them is deliberately fake:&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;$probes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'googlebot'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'gptbot'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mozilla/5.0 (compatible; GPTBot/1.1; +https://openai.com/gptbot)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'browser'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 …'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="c1"&gt;// the control: a user agent that exists nowhere,&lt;/span&gt;
    &lt;span class="c1"&gt;// matches no rule, and is on nobody's allowlist&lt;/span&gt;
    &lt;span class="s1"&gt;'control'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'NessFlowReachabilityProbe/1.0'&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;The control is the whole point. On its own, &lt;em&gt;"Googlebot got challenged"&lt;/em&gt; tells you nothing, because maybe the edge challenges everyone. Compared against a user agent no rule could possibly target, it becomes a diagnosis:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Googlebot UA&lt;/th&gt;
&lt;th&gt;Control UA&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;td&gt;challenged&lt;/td&gt;
&lt;td&gt;Verified-bot allowlist. &lt;strong&gt;No third-party crawler will ever see what Google sees.&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;challenged&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;td&gt;UA-based blocking of known bots. Crude, and usually accidental.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;challenged&lt;/td&gt;
&lt;td&gt;challenged&lt;/td&gt;
&lt;td&gt;Global edge rule. The site is closed to every automated client, Google included.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;td&gt;passes&lt;/td&gt;
&lt;td&gt;Open. Thin pages are a rendering problem, not an access problem.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note what the probe does &lt;em&gt;not&lt;/em&gt; do: work around the answer. It reports the wall, it doesn't climb it. When the wall is real, the audit moves server-side.&lt;/p&gt;

&lt;h3&gt;
  
  
  The no-JS ratio
&lt;/h3&gt;

&lt;p&gt;For every page we keep both views, raw HTML and rendered DOM, and compare word counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ratio = words(raw_html) / words(rendered_dom)

flag JavaScript-Only Content when:
    ratio &amp;lt; 0.3
  AND words(rendered_dom) &amp;gt;= 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 200-word floor matters more than the threshold. Without it, every legitimately short page (a thin category, a sold-out product, a redirect stub) trips the rule and the report becomes noise. With it, the flag means something specific: &lt;em&gt;this page has real content, and a client that doesn't execute JavaScript sees almost none of it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run it &lt;strong&gt;per template&lt;/strong&gt;, not per page. The flag almost never lights up uniformly. In practice it concentrates exactly where client-side filtering lives, in category and facet templates, which is also, per the section above, where the demand is. That coincidence is the finding. A JS-only PDP is a long-tail annoyance. A JS-only category template is the head of your demand curve.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that isn't technical
&lt;/h2&gt;

&lt;p&gt;Googlebot renders JavaScript. It has for years, on a delay, and a well-built PWA can rank perfectly well. That fact has been used for a decade to close the conversation: &lt;em&gt;"Google handles it, move on."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The crawlers that feed AI answers don't.&lt;/strong&gt; Per &lt;a href="https://vercel.com/blog/the-rise-of-the-ai-crawler" rel="noopener noreferrer"&gt;Vercel's crawler analysis&lt;/a&gt;, GPTBot fetched JavaScript files in a minority of requests and never executed them; ClaudeBot downloaded JS and never executed it either. GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot and PerplexityBot parse the HTML they first receive, take what's there, and move on. Bingbot renders partially and unreliably at scale.&lt;/p&gt;

&lt;p&gt;Put those two facts side by side and a PWA storefront lands somewhere very specific:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;It ranks in Google, and it does not exist in ChatGPT, Claude or Perplexity.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "ranks poorly." Does not exist. When someone asks an assistant &lt;em&gt;"where can I buy organic rice online in [country]"&lt;/em&gt;, the retrieval layer reads a shell with a nav bar and no products. The competitor on a server-rendered theme gets cited instead, and the gap is invisible in every rank tracker and every Search Console report, because neither measures retrieval by AI crawlers.&lt;/p&gt;

&lt;p&gt;That reframes the no-JS ratio entirely. It isn't technical hygiene. It's the answer to &lt;em&gt;"are we in the answer set?"&lt;/em&gt;, and for a grocery chain the queries at stake are the high-intent ones: where to buy, delivery, availability, price.&lt;/p&gt;

&lt;p&gt;One caveat I'd insist on: &lt;strong&gt;don't take Vercel's numbers, or mine, as your own.&lt;/strong&gt; Crawler behavior changes quietly and varies per site. That's the argument for having logs at all. You can measure it directly on your own domain: which AI user agents arrived, what they requested, whether they ever fetched the JS bundles, whether they came back. We track 19 AI crawlers with their declared purpose (training / search / user-request) and whether they respect robots.txt, because several don't, by design.&lt;/p&gt;




&lt;h2&gt;
  
  
  On log analysis, and giving credit
&lt;/h2&gt;

&lt;p&gt;Screaming Frog ships a &lt;a href="https://www.screamingfrog.co.uk/log-file-analyser/" rel="noopener noreferrer"&gt;Log File Analyser&lt;/a&gt;, and it's a good tool. It verifies bots against spoofed user agents, handles large files, and has done so for years. Claiming log analysis as a novelty would be silly.&lt;/p&gt;

&lt;p&gt;The differences that made it worth building our own are narrow and specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's a &lt;strong&gt;desktop application&lt;/strong&gt;, project-based, disk-bound: you import a file and look at it. We needed a continuous server-side pipeline, because crawl behavior is a time series, not a snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Search Console join.&lt;/strong&gt; Ours joins crawl, GSC and logs on the same canonical URL hash, &lt;a href="https://dev.to/nessflow_8283f7335b896207/laravel-seo-one-registry-for-every-url-a-crawler-sees-3emm"&gt;the registry from Part 4&lt;/a&gt;, which is what turns three separate reports into one answer about where the budget actually goes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No AI-crawler taxonomy.&lt;/strong&gt; In 2026 that's the half of the log file that decides whether you're in the answer set.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our parser handles 13 log formats at roughly &lt;strong&gt;52,000 lines/second on a single node&lt;/strong&gt;, parsing, normalizing and joining to canonical URLs, with &lt;strong&gt;no IP ever persisted&lt;/strong&gt;. IPs are used in memory for reverse-DNS bot verification and discarded; they never reach an analysis table, and never reach a model prompt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three things I'd keep
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When a crawler stalls, name the wall&lt;/strong&gt;: access, rendering, or URL space. They fail identically from the outside and the fix for each makes at least one of the others worse. The control probe is four HTTP requests and it tells you which one you're facing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A crawl of 30,000 pages is not an audit.&lt;/strong&gt; Segment by template, find the few hundred URLs that carry demand, and spend the analysis there. In grocery that means categories, brands and store pages, not the catalog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Google renders JavaScript" stopped being a sufficient answer.&lt;/strong&gt; Check the ratio on your category templates, then check your logs for who actually came and what they got.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://nessflow.com/en" rel="noopener noreferrer"&gt;NessFlow&lt;/a&gt; is in private beta: crawl, Search Console and server logs joined on one canonical URL, with AI-crawler visibility built in. If you run a storefront that breaks crawlers, that's the profile I'm looking for. Reach out on &lt;a href="https://www.linkedin.com/in/tarek-morgene/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>php</category>
    </item>
    <item>
      <title>Laravel SEO: one registry for every URL a crawler sees</title>
      <dc:creator>NessFlow</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:03:13 +0000</pubDate>
      <link>https://dev.to/nessflow_8283f7335b896207/laravel-seo-one-registry-for-every-url-a-crawler-sees-3emm</link>
      <guid>https://dev.to/nessflow_8283f7335b896207/laravel-seo-one-registry-for-every-url-a-crawler-sees-3emm</guid>
      <description>&lt;p&gt;&lt;em&gt;Laravel 13, Blade, two locales, no SEO package.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A bilingual public site usually carries four lists of the same URLs, and each one is an SEO surface. The routes file holds one. The sitemap builder holds a second. The layout that prints canonical and hreflang tags holds a third. The language switcher in the footer holds a fourth.&lt;/p&gt;

&lt;p&gt;Nothing makes those four agree, and they come apart in a specific way. Someone adds a page, the route lands, and the other three get updated by whoever remembers. When they do not, nothing happens. No exception, no failing test, and the page answers 200 to every visitor who has its address. The consequence surfaces weeks later in Search Console, as a page nobody ever crawled or a hreflang tag pointing at a 404. That delay is what makes URL drift an SEO problem rather than a bug: by the time it is visible, the commit that caused it is months old.&lt;/p&gt;

&lt;p&gt;We built our public site so those four lists cannot exist. There is one table, keyed by page, and everything a crawler sees is a projection of it. The site itself runs &lt;a href="https://nessflow.com/en/engineering/modern-marketing-site-without-modern-frontend-stack" rel="noopener noreferrer"&gt;inside the same Laravel application as the product&lt;/a&gt;, in Blade, which is what makes a single table plausible in the first place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;31 page keys, two locales, 136 sitemap URLs on the day this was written.&lt;/strong&gt; One expression decides every SEO surface of the site: the routes, the canonicals, the hreflang pairs, the x-default, the sitemap, the Open Graph card, the language switcher and the navigation. Fifty files in the application read it, and forty nine test files hold it in place.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A page is a key, not a URL
&lt;/h2&gt;

&lt;p&gt;The unit is a stable key. &lt;code&gt;solutions.agencies&lt;/code&gt; is a page. Its French and English addresses are values.&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="s1"&gt;'solutions.agencies'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'fr-FR'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'solutions/agences-seo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'en-US'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'solutions/seo-agencies'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="s1"&gt;'hub.engineering'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'fr-FR'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ingenierie'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'en-US'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'engineering'&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;Naming the page rather than the URL is what lets a language switcher point at the same page in the other language instead of falling back to the homepage. That fallback is the most common defect on multilingual sites, and it costs the visitor precisely the page they were reading.&lt;/p&gt;

&lt;p&gt;The homepage slug is the empty string, which forces one decision to be explicit.&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;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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;It returns &lt;code&gt;null&lt;/code&gt;, never an empty string as a fallback. An empty string is the legitimate slug of the homepage, so using it as a failure value would silently point every missing page at &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routes are a projection of the table
&lt;/h2&gt;

&lt;p&gt;The routes file contains no literal slug at all.&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MarketingLocale&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$localeCode&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$localeAttributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$localeAttributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'prefix'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;SetMarketingLocale&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;':'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$localeCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CacheMarketingResponse&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'marketing.'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$localeAttributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'prefix'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$localeCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pricing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$localeCode&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;PricingController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pricing'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One group per locale, carrying that locale's literal prefix. A single &lt;code&gt;{locale}&lt;/code&gt; group would have been shorter, and it would have answered 200 to every slug under every prefix: &lt;code&gt;/en/notre-approche&lt;/code&gt; and &lt;code&gt;/en/our-approach&lt;/code&gt; both serving the same page, two URLs per page per language. That is the duplicate content our own SEO product teaches its users to hunt, so the shape of the route file is pinned by a test rather than by a convention.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refuses one locale slug under another locale prefix'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/en/notre-approche'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertNotFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/fr/our-approach'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertNotFound&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;The routes do get names, &lt;code&gt;marketing.fr.pricing&lt;/code&gt; and &lt;code&gt;marketing.en.pricing&lt;/code&gt;. No view uses them. Views resolve addresses through the registry, because the registry is the thing that guarantees navigation, hreflang and sitemap agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canonical, hreflang and x-default: three SEO tags, one read
&lt;/h2&gt;

&lt;p&gt;The layout resolves two values, once, at the top.&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;$canonical&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$canonicalOverride&lt;/span&gt;  &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pageKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$marketingLocale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$alternates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$alternatesOverride&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$pageKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two values are everything the head prints on the subject.&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;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ $canonical }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

@foreach ($alternates as $hreflang =&amp;gt; $url)
    &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"{{ $hreflang }}"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ $url }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
@endforeach
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;alternates()&lt;/code&gt; walks the configured locales and returns only the pairs that exist.&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;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&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;$alternates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MarketingLocale&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attributes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&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="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$alternates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$attributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'hreflang'&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&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="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$alternates&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;Declaring a hreflang toward a page that does not exist is a crawl error, not a courtesy, so a missing pair is skipped rather than guessed. That single array then feeds three surfaces in the same request: the &lt;code&gt;&amp;lt;link rel="alternate"&amp;gt;&lt;/code&gt; tags, the &lt;code&gt;og:locale:alternate&lt;/code&gt; metas, and the footer language switcher. One read, so the robot and the visitor cannot be looking at different sites.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;x-default&lt;/code&gt; is derived rather than declared. It points at whichever locale the root path serves when the visitor's &lt;code&gt;Accept-Language&lt;/code&gt; names nothing we know, which makes it a property of the fallback rule instead of a fifth list to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SEO sitemap is a loop, not a list
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MarketingLocale&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$locale&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="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$entries&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="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'alternates'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'lastmod'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pageLastmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;];&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;Item pages are appended after that loop: article pages and the AI bot directory entries, each deriving its path from its index page rather than from a path built by hand. On the day this was measured that came to 136 URLs, 62 straight from the registry and 74 derived from it.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;priority&lt;/code&gt; and no &lt;code&gt;changefreq&lt;/code&gt; anywhere in the output. Both have been ignored for years, and they survive in SEO folklore rather than in any engine's behaviour. Emitting them mostly suggests we are steering something.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lastmod&lt;/code&gt; is the field worth being strict about. The rule is not "pages that have a date in the database". It is "pages that already declare a &lt;code&gt;dateModified&lt;/code&gt; to a robot", read from the same expression that renders the page. Two surfaces qualify today, the article page and the changelog. Static pages get nothing, because a &lt;code&gt;lastmod&lt;/code&gt; recomputed on each request is wrong on each request, and that is exactly the kind of signal a search engine learns to discount. Emitting it is a net loss, not a neutral gain.&lt;/p&gt;

&lt;p&gt;That rule is an enumeration, and an enumeration drifts, so it is held by parity in both directions.&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;$declared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;declaredDateModified&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getContent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$sitemap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$lastmods&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The fact must coincide, not its precision of writing.&lt;/span&gt;
&lt;span class="nv"&gt;$left&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$declared&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$declared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$right&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$sitemap&lt;/span&gt;  &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sitemap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test fetches every registry page in both locales, parses the JSON-LD the page actually serves, and requires equivalence. A page that starts declaring a date without gaining a sitemap entry fails the suite, and so does the reverse.&lt;/p&gt;

&lt;p&gt;It also carries a witness, which is the part that took a second pass to get right.&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'No registry page declares a date: the test proves nothing.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"No divergence" is equally true of a sitemap with no &lt;code&gt;lastmod&lt;/code&gt; at all and pages that declare none. The fixture seeds exactly one dated page, so the test insists on finding exactly one before it is allowed to conclude.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defect a single source does not prevent
&lt;/h2&gt;

&lt;p&gt;Item pages cannot live in the registry. There are as many of them as there are rows in a table. They derive their path from their index instead.&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;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;childPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$indexKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$childSlug&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$indexKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$locale&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;$parent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$parent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$childSlug&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;Which means an item page has no key of its own, and for a while the layout resolved its canonical from the index key it was handed. Every article on the site declared its section listing as its canonical URL, and de-indexed itself in favour of a list. It is the most expensive SEO defect we have shipped, and it shipped green.&lt;/p&gt;

&lt;p&gt;Nothing about that was visible. The page rendered, the content was right, the tags were well formed, and every piece we published was asking search engines to ignore it.&lt;/p&gt;

&lt;p&gt;The fix is two override props, and the distinction between them is the part worth keeping.&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="s1"&gt;'canonicalOverride'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="s1"&gt;'alternatesOverride'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&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;null&lt;/code&gt; means no override. An empty array means no twin exists. A piece written natively in one language has no twin, and there is no correct hreflang for it. Collapsing the two values into one would make the empty case inherit the index alternates and send English readers to a French page that does not contain the article.&lt;/p&gt;

&lt;p&gt;That distinction is observable from outside. Of the 136 sitemap URLs measured for this piece, 132 carried two &lt;code&gt;xhtml:link&lt;/code&gt; entries and four carried one. Those four are the engineering notes, which are written in English and not translated. This one makes five.&lt;/p&gt;

&lt;h2&gt;
  
  
  One predicate for the page and for its absence
&lt;/h2&gt;

&lt;p&gt;Our data processing agreement is written and seeded, and it is not published. The text is done; what is missing is the set of clauses that legally commit the company. It enters the table only when its gate is open.&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="nc"&gt;TrustPack&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dpaIsPublished&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'trust.dpa'&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="s1"&gt;'fr-FR'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'accord-de-traitement-des-donnees'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'en-US'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'data-processing-agreement'&lt;/span&gt;&lt;span class="p"&gt;,&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;And the route file reads the result rather than the gate.&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;$dpaSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trust.dpa'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$localeCode&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="nv"&gt;$dpaSlug&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$dpaSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;TrustController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'dpa'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trust.dpa'&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;No slug means no route, which means a 404. And since the navigation, the sitemap, the hreflang tags and the language switcher all read the same table, none of them can cite it. Every SEO surface goes dark with the page, in the same gesture. A dead link and a phantom sitemap URL are structurally impossible, because the two faces switch on and off together.&lt;/p&gt;

&lt;p&gt;Note what is not there: an &lt;code&gt;abort(404)&lt;/code&gt; in the controller. Two guards saying the same thing mask each other under mutation testing. Each one is sufficient, so each survives alone, and neither is genuinely tested. The absence of the route is the 404.&lt;/p&gt;

&lt;p&gt;Eight of our nine module pillars carry a landing page on the same mechanism. Closing a pillar's gate closes its page, its sitemap entry, its hreflang, its Open Graph card and its megamenu link in one gesture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both directions, or it is not a source of truth
&lt;/h2&gt;

&lt;p&gt;One direction sweeps the registry and serves every page it names.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'serves every registry page in every locale'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The slug registry is empty.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$keys&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MarketingLocale&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;codes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Page [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] has no slug in [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Page [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] does not respond in [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$locale&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&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;That is 62 HTTP requests in one test. The emptiness witness matters more than it looks, because a gate can empty the registry, and an empty sweep otherwise reads as a complete one.&lt;/p&gt;

&lt;p&gt;The other direction walks the router.&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'router'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRoutes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&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="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'marketing.'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// marketing.fr.solutions.agencies becomes solutions.agencies&lt;/span&gt;
    &lt;span class="nv"&gt;$logical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^marketing\.[a-z]{2}\./'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$logical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Route [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] has no slug registry key."&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;This is the one that catches the mistake people actually make, which is adding a route and forgetting the table. A page served without a registry key has no hreflang, no twin in the language switcher and no sitemap entry. It has no SEO surface at all, and it looks perfect in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that cost us our datasets
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SlugRegistry::keys()&lt;/code&gt; was a static array for months, and Pest datasets consumed it, one case per page, with per page reporting in the output.&lt;/p&gt;

&lt;p&gt;Then a gated pillar received a landing page. The registry now calls &lt;code&gt;PillarCatalog::isPublished()&lt;/code&gt;, which reads &lt;code&gt;config('marketing.pillars.*')&lt;/code&gt;. A Pest dataset is resolved when the test case is constructed, before the application exists, container included. Wrapping it in a closure does not help, because the closure is resolved at that same moment.&lt;/p&gt;

&lt;p&gt;The symptom has nothing to do with the cause. First &lt;code&gt;expects 1 argument, but no dataset was provided&lt;/code&gt;, then &lt;code&gt;Target class [config] does not exist&lt;/code&gt;, raised about 150 lines away from the change that caused it.&lt;/p&gt;

&lt;p&gt;Keys are now iterated inside the test body, with the subject name in every failure message so the granular reporting is not lost. The corollary is repository wide: no dataset here can read configuration, and a registry that becomes gate dependent silently breaks every &lt;code&gt;with()&lt;/code&gt; that consumes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The suite grows with the site.&lt;/strong&gt; One registry sweep is 62 requests. The routing and SEO files together are 35 tests, 995 assertions and 16.0 seconds. The whole public site feature suite is 850 tests and 153,711 assertions in 4 minutes 3 seconds. Adding a page adds work to roughly forty nine files, and that is the deal: the SEO guarantee is paid for in wall clock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Datasets are unavailable, permanently.&lt;/strong&gt; Loops with named failure messages recover the reporting, and they are more verbose than the &lt;code&gt;with()&lt;/code&gt; they replaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One central file everyone edits.&lt;/strong&gt; The registry is 537 lines, mostly comment, and every new page touches it. It is a reliable source of merge conflicts, and we consider a conflict there a feature, because it is a conversation about a URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is rebuilt, not cached.&lt;/strong&gt; &lt;code&gt;map()&lt;/code&gt; costs 3.83 microseconds and &lt;code&gt;alternates()&lt;/code&gt; 14.03, measured over 10,000 calls. A page render calls them a handful of times, so the total stays under a tenth of a millisecond, and the response cache in front of it hides even that. It is still a rebuild on every call, with nothing memoising it. We would rather pay microseconds than own a cache invalidation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Item pages sit outside the guarantee.&lt;/strong&gt; Everything derived from the registry is proven in both directions. Everything derived from &lt;code&gt;childPath()&lt;/code&gt; is only as good as the overrides passed with it, which is exactly where the canonical defect lived, and exactly where the next one will.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What transfers
&lt;/h2&gt;

&lt;p&gt;Not the registry. The registry is small, specific to a two locale Blade site, and would be the wrong shape for a site with regional variants or a translation workflow. It also only works because the public site is &lt;a href="https://nessflow.com/en/engineering/blade-inertia-filament-on-purpose" rel="noopener noreferrer"&gt;not rendered by the single page application&lt;/a&gt; that serves the product.&lt;/p&gt;

&lt;p&gt;What transfers is the question that produced it. For any SEO fact a crawler can read from your site, ask how many places would have to be edited, in step, for that fact to stay true. If the answer is more than one, you do not have a source of truth. You have copies that agree today.&lt;/p&gt;

&lt;p&gt;The follow up is just as cheap. Once there is one place, write the test that walks it in both directions, and give it a witness so an empty sweep cannot pass for a complete one. Ours has been red exactly when it should be, which is the only evidence that matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Measurements taken 26 August 2026. Registry size, key list and micro benchmarks from the application on PHP 8.4.23, Apple M5 Max, 10,000 iterations per benchmark. Sitemap composition parsed from the live production sitemap on the same day, 136 URLs, 268 &lt;code&gt;xhtml:link&lt;/code&gt; elements, 38 &lt;code&gt;lastmod&lt;/code&gt; elements. Test counts and durations from a local Pest 5 run, single process, SQLite in memory.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nessflow.com/en/engineering/laravel-seo-one-registry-for-every-url-a-crawler-sees" rel="noopener noreferrer"&gt;nessflow.com&lt;/a&gt;.&lt;br&gt;
I write about how NessFlow is built at &lt;a href="https://nessflow.com/en/engineering" rel="noopener noreferrer"&gt;nessflow.com/en/engineering&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Millions of log lines in PHP, at constant memory</title>
      <dc:creator>NessFlow</dc:creator>
      <pubDate>Thu, 20 Aug 2026 16:09:01 +0000</pubDate>
      <link>https://dev.to/nessflow_8283f7335b896207/millions-of-log-lines-in-php-at-constant-memory-i7n</link>
      <guid>https://dev.to/nessflow_8283f7335b896207/millions-of-log-lines-in-php-at-constant-memory-i7n</guid>
      <description>&lt;p&gt;&lt;em&gt;Laravel 13, Horizon, PostgreSQL 18.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our product needs to answer a question about a customer's site: which crawler fetched which URL, on which day, and what status code did it get. Not "how many hits yesterday". The cross product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a log analyzer
&lt;/h2&gt;

&lt;p&gt;We started with GoAccess, which is an excellent tool, and abandoned it. The reason is worth stating precisely because it is the decision the rest of this article follows from.&lt;/p&gt;

&lt;p&gt;GoAccess produces a &lt;code&gt;report.json&lt;/code&gt; containing panels: top URLs, top user agents, status code distribution, hits per day. Each panel is already aggregated, and no panel is crossed with any other. So it can tell you that Googlebot fetched 40,000 pages, and separately that 3,000 requests returned 404. It cannot tell you whether Googlebot got any of those 404s.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An aggregate you cannot cross is not data. It is a picture of data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What we needed was a cube: date, hour, bot, URL, status code, with hit counts and bytes. Once that is the requirement, no report-producing tool helps, because the aggregation has to happen on our axes. So the parser is ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one line costs
&lt;/h2&gt;

&lt;p&gt;Thirteen input formats are supported. Seven of them are variations on the common log format and share one engine: GoAccess style format strings compiled into a regex once, then applied per line.&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="c1"&gt;// app/Services/Logs/Parsers/FormatStringLineParser.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%h %^[%d:%t %z] "%r" %s %b "%R" "%u"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%d/%b/%Y'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'%T'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;amazonS3&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%^ %^ [%d:%t %z] %h %^ %^ %^ %^ "%r" %s %^ %b %^ %^ %^ "%R" "%u"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&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;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;squidNative&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%x %^ %h %^/%s %b %m %U %^ %^ %^'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;EPOCH&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;&lt;code&gt;%^&lt;/code&gt; means "a field is here, skip it", which is what makes a 17 column S3 line expressible in one string. &lt;code&gt;%z&lt;/code&gt; is ours, not GoAccess's: it captures the UTC offset so that every timestamp is normalized to UTC at parse time rather than at query time. Getting that wrong is how a daily report ends up with 25 hours in it twice a year.&lt;/p&gt;

&lt;p&gt;The other formats do not fit a format string and get dedicated parsers: Cloudflare JSON, Caddy JSON, Google Cloud Storage CSV, and a W3C parser that is stateful because IIS declares its columns in a &lt;code&gt;#Fields:&lt;/code&gt; header partway through the file. That same W3C parser serves CloudFront, whose lines are URL encoded and whose spaces arrive as &lt;code&gt;+&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Benchmarked on this machine on 19 August 2026, running the real three stages: parse the line, classify the user agent, normalize the URL. The corpus is synthetic but deliberately hostile to memoization, with 5,000 distinct paths and 240 distinct agent strings.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Throughput&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;86.6 MB&lt;/td&gt;
&lt;td&gt;5.91 s&lt;/td&gt;
&lt;td&gt;84,550 /s&lt;/td&gt;
&lt;td&gt;44.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two honest caveats. Real production logs measured slower, around 52,000 lines per second, because real user agent strings are longer and messier than generated ones. And a synthetic corpus flatters any parser.&lt;/p&gt;

&lt;p&gt;The number that matters is not the throughput. It is that &lt;strong&gt;peak memory was 44.5 MB in this run and 44.5 MB in the previous run with a fraction of the cardinality&lt;/strong&gt;. It does not move, because nothing accumulates. A 2 GB file uses the same 44.5 MB as an 86 MB one, and the job's memory limit is therefore a constant you can reason about instead of a function of what a customer uploads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cube, and the upsert that makes partial work safe
&lt;/h2&gt;

&lt;p&gt;Aggregates land in one table whose unique key is the cube itself.&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;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'project_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'date'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bot_token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'url_hash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status_code'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bot_token'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// sentinel, never null&lt;/span&gt;
&lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'url_hash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// sha-256 of the normalized URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bot_token&lt;/code&gt; defaults to an empty string and is never null, because a null inside a unique key means the key stops deduplicating: two rows with a null bot are distinct as far as the index is concerned. PostgreSQL has &lt;code&gt;NULLS NOT DISTINCT&lt;/code&gt; to fix that. SQLite, which our tests run on, does not. So the sentinel is the portable answer, and every column in a uniqueness key is &lt;code&gt;NOT NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The write is an additive upsert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;project_log_aggregates&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hit_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_transferred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bot_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;
    &lt;span class="n"&gt;hit_count&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;project_log_aggregates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hit_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hit_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bytes_transferred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;project_log_aggregates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes_transferred&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes_transferred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding rather than replacing is what makes the whole pipeline restartable. A flush that wrote half a buffer, whose keys then reappear in a later flush, is still correct. Two log files from two servers covering the same hour merge instead of overwriting each other. The &lt;code&gt;excluded&lt;/code&gt; pseudo-table is portable across PostgreSQL and SQLite, which matters because the tests run on one and production on the other.&lt;/p&gt;

&lt;p&gt;Two constraints come with that choice, and both are load bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intra-batch duplicates have to be impossible, not merely unlikely.&lt;/strong&gt; PostgreSQL refuses an &lt;code&gt;ON CONFLICT&lt;/code&gt; statement that contains the same conflict key twice in one batch. Our buffer is keyed by the cube, so a duplicate cannot exist by construction; deduplication is structural rather than a step someone could forget. The chunk size is 500 rows, which bounds the number of bindings per statement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replaying the same file would double the volumes.&lt;/strong&gt; Addition has no idempotence of its own, so idempotence is enforced upstream, by a SHA-256 of the uploaded file computed server side while the chunks are assembled. The check is in application code rather than a SQL unique index, deliberately: a run that failed must remain re-uploadable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that ate its own error handler
&lt;/h2&gt;

&lt;p&gt;This one cost us a production incident and it is the most transferable thing in this article.&lt;/p&gt;

&lt;p&gt;Real access logs contain bytes that are not valid UTF-8, and sometimes null bytes, because scanners send binary payloads at your server and your server logs the request line. PostgreSQL rejects both with SQLSTATE 22021. Fine: the ingest catches the exception and writes it to the run's &lt;code&gt;error_message&lt;/code&gt; column so the customer sees a failure instead of a spinner.&lt;/p&gt;

&lt;p&gt;Except that &lt;strong&gt;a Laravel &lt;code&gt;QueryException&lt;/code&gt; message contains the SQL with its bindings interpolated&lt;/strong&gt;. So the message about the invalid byte contains the invalid byte. Writing it fails with the identical 22021. And because that write also happens in &lt;code&gt;failed()&lt;/code&gt;, the retry fails the same way.&lt;/p&gt;

&lt;p&gt;The run stayed in &lt;code&gt;processing&lt;/code&gt; forever. The uploaded file had already been purged, correctly, so there was nothing to retry. The interface showed a job in progress that no longer existed.&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;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sanitizeErrorMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$message&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="nb"&gt;mb_check_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_convert_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&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;The same scrubbing applies at the other end of the pipeline, in the URL normalizer, which also strips tracking parameters and caps length at 2,048 bytes using &lt;code&gt;mb_strcut&lt;/code&gt; so a multibyte character is never sliced in half.&lt;/p&gt;

&lt;p&gt;The part worth underlining: &lt;strong&gt;SQLite accepts every one of those bytes happily.&lt;/strong&gt; No test we could have written on the test database would have found this. It is a whole class of defect that is &lt;a href="https://nessflow.com/en/engineering/blade-inertia-filament-on-purpose" rel="noopener noreferrer"&gt;green in CI and fatal in production&lt;/a&gt;, and the only defence is knowing it exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queue discipline for a job that cannot be retried
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// dedicated queue: logs-processing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One attempt, on purpose. A file that was half ingested must never be replayed, because the upsert adds. The dedicated queue exists so that a customer uploading a 2 GB archive cannot starve every other job in the system for half an hour, and it needs its own Horizon supervisor, which in turn means Horizon has to be restarted on deploy or the new code never runs.&lt;/p&gt;

&lt;p&gt;The upload itself is chunked and hand written rather than a single multipart POST: 4 MB binary chunks, the SHA-256 accumulated server side as they are assembled, and a &lt;code&gt;422&lt;/code&gt; on completion that returns the list of missing chunk indexes so the client re-pushes only those. Resumable uploads are not a feature we wanted to build, they are what a 2 GB file on a hotel connection requires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention that keeps the answer and drops the resolution
&lt;/h2&gt;

&lt;p&gt;A cube grows. Ours drops resolution before it drops 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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;DAILY_AFTER_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;PURGE_AFTER_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Past 90 days, the 24 hourly rows of a given day, bot, URL and status collapse into a single row at &lt;code&gt;hour = 0&lt;/code&gt;, using the same additive upsert as the ingest, inside one transaction, and the hourly rows are then deleted. Past 400 days, rows go.&lt;/p&gt;

&lt;p&gt;This is only safe because no analysis query filters on &lt;code&gt;hour&lt;/code&gt;: the column exists for ingest fidelity, not for reporting. Checking that before writing the compaction is the entire difference between a retention job and a data loss incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  No IP address is stored, and the nuance matters
&lt;/h2&gt;

&lt;p&gt;The cube has no IP column. Nothing in the log services persists a client address, and nothing sends one to a model.&lt;/p&gt;

&lt;p&gt;But the parser does read it, and pretending otherwise would be a lie: the common log format is positional, so &lt;code&gt;%h&lt;/code&gt; has to be captured for the fields after it to line up. The address exists in memory for the lifetime of one line and is then discarded with it.&lt;/p&gt;

&lt;p&gt;That distinction is the honest version of the claim, and it is also the useful one. "We do not process IP addresses" would be false. "No client address is written to disk or leaves the machine" is true, verifiable by looking at the schema, and it is what a customer handing over their server logs actually needs to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reverse DNS verification is still missing.&lt;/strong&gt; We classify crawlers by user agent, which is trivially spoofable. Verifying that a self declared Googlebot actually comes from Google requires a reverse lookup followed by a forward confirmation, and we have not built it. Until then, our bot attribution is a declared identity, not a verified one, and the interface should say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The throughput number is machine dependent and we treat it as such.&lt;/strong&gt; The figure in our own engineering journal was 52,000 lines per second. Re-measuring it for this article gave 84,550 on different hardware with a friendlier corpus. Both are true, neither is "the" number, and a written measurement is an observation with a date rather than a property of the system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming was not the hard part.&lt;/strong&gt; Reading a file line by line in PHP is a &lt;code&gt;while&lt;/code&gt; loop. The hard parts were the byte level hostility of real logs, the portability of one SQL statement across two engines, and deciding what a partially completed ingest is allowed to mean.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Benchmarked 19 August 2026 on a synthetic 500,000 line COMBINED corpus, 86.6 MB, 5,000 distinct paths and 240 distinct user agent strings, running FormatStringLineParser, LogBotRegistry and LogUrlNormalizer in sequence. Peak memory via memory_get_peak_usage. Production figure of 52,000 lines per second measured 21 July 2026 on real access logs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nessflow.com/en/engineering/parsing-millions-log-lines-php-constant-memory" rel="noopener noreferrer"&gt;nessflow.com&lt;/a&gt;.&lt;br&gt;
I write about how NessFlow is built at &lt;a href="https://nessflow.com/en/engineering" rel="noopener noreferrer"&gt;nessflow.com/en/engineering&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>performance</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Blade, Inertia and Filament, on purpose</title>
      <dc:creator>NessFlow</dc:creator>
      <pubDate>Thu, 20 Aug 2026 16:06:41 +0000</pubDate>
      <link>https://dev.to/nessflow_8283f7335b896207/blade-inertia-and-filament-on-purpose-3amn</link>
      <guid>https://dev.to/nessflow_8283f7335b896207/blade-inertia-and-filament-on-purpose-3amn</guid>
      <description>&lt;p&gt;&lt;em&gt;Laravel 13, Inertia 3, React 19, Filament 5.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Part one covered the public site: 26 Blade views, no CDN, no front-end framework, 4,323 bytes of JavaScript. It lives beside a product of 50 Inertia pages and an admin console we did not write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why each world
&lt;/h2&gt;

&lt;p&gt;Each of the three is here because it is the best available answer to one specific problem. The clearest way to show that is one example each.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blade: markup and structured data cannot disagree
&lt;/h3&gt;

&lt;p&gt;Server rendered by default, no hydration, no serialization bridge. The strength shows up somewhere unglamorous. Our breadcrumb component emits the visible navigation and the JSON-LD from the same array, in the same render.&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="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"{{ __('marketing.nav.breadcrumb') }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    @foreach ($items as $item)
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;aria-current=&lt;/span&gt;&lt;span class="s"&gt;"page"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ $item['name'] }}&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    @endforeach
&lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt; &lt;span class="nf"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$jsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON_UNESCAPED_SLASHES&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;JSON_UNESCAPED_UNICODE&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;$items&lt;/code&gt;, two consumers: the human and the crawler. They cannot drift, because there is nothing between them to drift through. The same page built with client rendering has two code paths to the same truth, and a rich result that silently stops matching the page is a genuinely miserable bug to find.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inertia: typed props, and no API to maintain
&lt;/h3&gt;

&lt;p&gt;An audit tool is dense: sortable tables, charts, filter panels, virtualized lists. React has mature accessible answers for all of it, and Inertia removes the expensive part of a single page application, which is the API. A controller returns a readonly DTO and the page receives it.&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="c1"&gt;// Controller&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'issues/index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'crawl'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CrawlSummary&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$crawl&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'groups'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$groups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// app/Data/Seo/CrawlSummary.php, aligned with resources/js/types/seo.ts&lt;/span&gt;
&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CrawlSummary&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @param array{discovered: int, crawled: int, depth: int}  $stats */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$progress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$stats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$isTruncated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&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;No client store to keep in sync, no serializer layer, no second set of routes to version, no endpoint that has to be deprecated on its own schedule. Static analysis checks the PHP side, TypeScript checks the React side, and the shape is written once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filament: the least differentiated code in the product, for free
&lt;/h3&gt;

&lt;p&gt;A back office is CRUD, and CRUD is the least differentiated code anyone writes. Two chained calls produce a searchable, sortable, paginated column.&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="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole argument. Its Pest integration means the panel is tested like the rest of the application rather than quietly exempted from testing, the ecosystem is unusually rich for a Laravel package, and we already know Livewire and Alpine for the rare resource that needs something specific.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One precision.&lt;/strong&gt; Livewire is not a direct dependency. The &lt;code&gt;composer.json&lt;/code&gt; requires &lt;code&gt;filament/filament&lt;/code&gt;, there is no &lt;code&gt;app/Livewire&lt;/code&gt; directory, and we have never written a Livewire component by hand. Livewire arrives with Filament, which is a different decision with different consequences.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What one origin buys
&lt;/h2&gt;

&lt;p&gt;The three worlds are served by one application, from one origin, over one session, against one database. Both &lt;a href="https://nessflow.com/en/engineering/modern-marketing-site-without-modern-frontend-stack" rel="noopener noreferrer"&gt;build entries&lt;/a&gt; import the same token sheet, so the product and the public site cannot drift apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="nt"&gt;app&lt;/span&gt;&lt;span class="nc"&gt;.css&lt;/span&gt; &lt;span class="nt"&gt;and&lt;/span&gt; &lt;span class="nt"&gt;marketing&lt;/span&gt;&lt;span class="nc"&gt;.css&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;two&lt;/span&gt; &lt;span class="nt"&gt;entries&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;one&lt;/span&gt; &lt;span class="nt"&gt;source&lt;/span&gt; &lt;span class="nt"&gt;of&lt;/span&gt; &lt;span class="nt"&gt;truth&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;'./theme.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cross world state, in this arrangement, is a cookie and a stylesheet. On separate origins it is a specification. That is the whole argument, and everything below is the price of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seam one: a preference that never reaches a request
&lt;/h2&gt;

&lt;p&gt;Light and dark is stored client side. Two of our worlds used two different storage keys, which did not produce two independent settings: it produced one setting that was forgotten on every crossing. A visitor who switched to dark on a public page found the product in light, and the reverse.&lt;/p&gt;

&lt;p&gt;Both worlds now read one contract, declared twice and mirrored explicitly.&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="c1"&gt;// app/Support/Appearance.php&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Appearance&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STORAGE_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'nessflow-appearance'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;COOKIE&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'nessflow-appearance'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// resources/js/lib/appearance.ts&lt;/span&gt;
&lt;span class="cd"&gt;/** Mirror of App\Support\Appearance::STORAGE_KEY. */&lt;/span&gt;
&lt;span class="n"&gt;export&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;APPEARANCE_STORAGE_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'nessflow-appearance'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details are worth more than the key itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is a third copy of this predicate and it is irreducible.&lt;/strong&gt; An inline Blade script has to resolve the theme before the first paint, which means before any bundle exists. Its parity with the TypeScript module is held by a test, so changing one without the other fails the suite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Auto" is the absence of a key, never the string &lt;code&gt;system&lt;/code&gt;.&lt;/strong&gt; Three surfaces resolve the theme before first render. A third stored value would have to be known by all three, and forgetting it in one place renders a light page on a dark machine: a theme flash nobody reproduces in development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cookie is not the source of truth.&lt;/strong&gt; It is the server side mirror of local storage, and it exists for exactly one purpose: letting the Inertia shell put &lt;code&gt;class="dark"&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; before any script runs. On divergence, local storage wins, because it is the one that survives a cookie purge. Reading it also has to be wrapped, since in private browsing the read itself throws and would take the theme render down with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The test that had to be written against the machine
&lt;/h3&gt;

&lt;p&gt;No feature test can see any of this, because nothing is wrong with either HTTP response. The guard is a browser test, and it has one property worth copying.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'carries the theme chosen on the public site into the product'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SlugRegistry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'home'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fr-FR'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;inLightMode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertNoJavaScriptErrors&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'footer [data-appearance-choice="dark"]'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/login'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;documentIsDark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'The product forgot the theme chosen on the public site.'&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;The visitor picks &lt;strong&gt;dark on a machine set to light&lt;/strong&gt;, and in the mirrored test light on a machine set to dark. Without running against the system preference, both worlds could simply follow the operating system on their own and the test would pass on precisely the broken state it exists to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seam two: two definitions of the current language
&lt;/h2&gt;

&lt;p&gt;The server derived the locale from our cookie and fell back to &lt;code&gt;Accept-Language&lt;/code&gt;. The front end read the same cookie and fell back to French. On the first request of a session, before the cookie exists, a browser configured in English produced English validation messages, an English &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt; and English exports, inside an interface that was entirely French.&lt;/p&gt;

&lt;p&gt;The fix was a deletion. The header branch could produce no benefit, because the front end never reads that header, so the interface stayed French regardless. It could only manufacture divergence.&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="c1"&gt;// app/Http/Middleware/SetLocale.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;CODES&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="cd"&gt;/** The fallback is FRENCH, the front-end runtime's own, and NOT
    config('app.locale'), which is 'en' and would reopen the divergence. */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;FALLBACK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$declared&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$declared&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$declared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CODES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$declared&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FALLBACK&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;The predicate is isolated in a static method so it can be argued with. Nothing else takes part: no header, no request body. Removing one branch corrected ten call sites at once instead of propagating a workaround into each of them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A locale predicate is set on both sides of a boundary or on neither. Outside HTTP, in a queued job or a console command, there is no request and no cookie, so the language of a generated artifact travels as a parameter rather than being inferred.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Stated plainly, because it is a real trade: the product no longer detects browser language. It never did on screen. If detection becomes a requirement it belongs to the front end, which can write the cookie, the only place where the answer is true on both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seam three: leaving the single page application
&lt;/h2&gt;

&lt;p&gt;Signing out is an XHR request. Fortify answered &lt;code&gt;302&lt;/code&gt; to &lt;code&gt;/&lt;/code&gt;, the client followed the redirect, received the complete HTML of the marketing home, found no &lt;code&gt;X-Inertia&lt;/code&gt; header in it, and opened its &lt;code&gt;&amp;lt;dialog id="inertia-error-dialog"&amp;gt;&lt;/code&gt; with the public site inside an iframe.&lt;/p&gt;

&lt;p&gt;No error. No log. The user sits on &lt;code&gt;/dashboard&lt;/code&gt; with the marketing site overlaid on top, address bar unchanged, already signed out.&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="c1"&gt;// app/Http/Responses/LogoutResponse.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;wantsJson&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Inertia&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;location&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Fortify&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;redirects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'logout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&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;&lt;code&gt;Inertia::location()&lt;/code&gt; answers &lt;code&gt;409&lt;/code&gt; with an &lt;code&gt;X-Inertia-Location&lt;/code&gt; header, which the client translates into a full page load. Outside an Inertia request, the same call degrades to an ordinary redirect, so the contract of the route does not change for a bare form, an HTTP test or an API client.&lt;/p&gt;

&lt;p&gt;Four exits cross worlds this way: signing out, deleting an account, leaving impersonation, and heading to checkout. The guard is unusual in that no content assertion can express it.&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="c1"&gt;// Only the response CODE says it. Nothing in the body moves.&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Inertia-Location'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertGuest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Seam four: the panel is authenticated, so it sees the tenant scope
&lt;/h2&gt;

&lt;p&gt;56 of our 77 models carry a trait whose global scope keys on &lt;code&gt;Auth::user()?-&amp;gt;current_team_id&lt;/code&gt;. That scope is the backbone of tenant isolation. The Filament panel is authenticated, so every Eloquent read of a business model inside it is silently restricted to the &lt;em&gt;administrator's&lt;/em&gt; team rather than to the team on the row being looked at.&lt;/p&gt;

&lt;p&gt;The symptom is treacherous because nothing raises. A &lt;code&gt;withCount('projects')&lt;/code&gt; on a list of teams returns the administrator's project count on their own row, and &lt;strong&gt;zero on every other row&lt;/strong&gt;. A back office showing "0 projects" across an entire fleet looks like an empty fleet, not like a bug. Sorting on that column then ranks teams in an order that means nothing.&lt;/p&gt;

&lt;p&gt;We shipped that in two places: the Projects column of the teams list, and the ranking of a "Top Active Teams" widget.&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="c1"&gt;// app/Filament/Support/CrossTenant.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;unscoped&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withoutGlobalScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Every panel read declares what it wants to read.&lt;/span&gt;
&lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projects_count'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'projects'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CrossTenant&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;unscoped&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions inside that small class matter more than the class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The escape is per read, never global.&lt;/strong&gt; Neutralizing the scope for the whole panel is tempting and wrong: the same human also browses the product, and a scope conditioned on the current URL is exactly the kind of rule that eventually applies at the wrong moment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The relation constraint is not removed, only the ambient filter.&lt;/strong&gt; &lt;code&gt;projects.team_id = teams.id&lt;/code&gt; still holds, so the count remains the count of the right team, which is the team on the row.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;A stale storage key. A fallback chain disagreeing with another fallback chain. A redirect rendering in the wrong container. A protective scope doing its job in the wrong context.&lt;/p&gt;

&lt;p&gt;None of them threw. All of them produced output a reviewer would approve. And no feature test could have caught a single one, because in every case the server answered correctly.&lt;/p&gt;

&lt;p&gt;So the seams get a small, specific category of test that the middle of the application never needs: browser tests that genuinely cross from one world into another, response code assertions at the crossing points, and parity tests between surfaces that must agree. It is a handful of tests, and they are the only ones we have that can fail for reasons nothing else can express.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and when we would not do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three mental models.&lt;/strong&gt; Free for a team that knows all three. Not free for a team learning one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three styling contexts.&lt;/strong&gt; The panel does not load the product stylesheet, and its theming goes through a palette rather than CSS, which is a different skill from writing Tailwind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The seams are permanent.&lt;/strong&gt; Cheap once understood, but they never disappear, and every new crossing point is a new chance at a silent failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It rewards one repository.&lt;/strong&gt; Everything good here follows from one origin, one session and one database. Split those and most of the argument goes with them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same product, same team, we would make the same three choices tomorrow. It is not a compromise between frameworks. It is three tools doing what each is best at, sharing everything worth sharing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Next.&lt;/strong&gt; Part three: &lt;a href="https://nessflow.com/en/engineering/parsing-millions-log-lines-php-constant-memory" rel="noopener noreferrer"&gt;processing millions of server log lines&lt;/a&gt; with Laravel, Horizon and a hand written streaming parser.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Verified in the repository on 19 August 2026: 26 public Blade views, 50 Inertia page components, 56 of 77 models carrying the team scope, 4 cross world exits, 10 locale call sites corrected by removing one fallback branch.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nessflow.com/en/engineering/blade-inertia-filament-on-purpose" rel="noopener noreferrer"&gt;nessflow.com&lt;/a&gt;.&lt;br&gt;
I write about how NessFlow is built at &lt;a href="https://nessflow.com/en/engineering" rel="noopener noreferrer"&gt;nessflow.com/en/engineering&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>react</category>
      <category>architecture</category>
    </item>
    <item>
      <title>A modern marketing site without a modern front-end stack</title>
      <dc:creator>NessFlow</dc:creator>
      <pubDate>Thu, 20 Aug 2026 16:04:05 +0000</pubDate>
      <link>https://dev.to/nessflow_8283f7335b896207/a-modern-marketing-site-without-a-modern-front-end-stack-2a12</link>
      <guid>https://dev.to/nessflow_8283f7335b896207/a-modern-marketing-site-without-a-modern-front-end-stack-2a12</guid>
      <description>&lt;p&gt;&lt;em&gt;Laravel 13, Blade, Tailwind 4, Vite 8.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Astro, Hugo, Eleventy and Next are all good at building fast marketing sites. This is not a comparison, and there is no version of this article where one of them loses. Every one of those tools carries real advantages and real costs, and choosing between them is a question of context.&lt;/p&gt;

&lt;p&gt;This is our context, and these are our numbers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;4,323 bytes.&lt;/strong&gt; Gzipped JavaScript on a public page, including navigation, the theme switcher and audience measurement. Four modules, hand written, no framework.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The entry number, and where it comes from
&lt;/h2&gt;

&lt;p&gt;A PageSpeed run on 17 August 2026 at 19:51, mobile, returns 99 for performance and 100 for accessibility, best practices and SEO. Those four numbers come from a lab pass on a single URL. The field data section reads "no data", because the site is too young and too lightly trafficked to appear in the user experience report.&lt;/p&gt;

&lt;p&gt;Lab scores are cheap. They are also the only thing most teams publish, which is why the rest of this article is about the decisions underneath them, including one that consists of refusing to trust one of those four numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did not install
&lt;/h2&gt;

&lt;p&gt;Four absences, all verifiable from outside in a few seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No CDN.&lt;/strong&gt; The apex domain and its &lt;code&gt;www&lt;/code&gt; subdomain resolve straight to an OVH server. No intermediate proxy, no edge network, no distributed cache rules to reason about when a page looks stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No asset domain.&lt;/strong&gt; There is no &lt;code&gt;ASSET_URL&lt;/code&gt;. Stylesheets, scripts and fonts come from the same origin as the HTML, through the same web server, under the same cache policy and the same certificate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No front-end framework for the site.&lt;/strong&gt; The 26 public views are Blade views. The 28 public routes go through no single page application layer. There is no second repository, no second pipeline, no second deployment model, and no second place where content can drift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No flat-file CMS and no headless CMS.&lt;/strong&gt; This is the absence people find most surprising, so it gets its own section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two build entries, and why that is the load-bearing decision
&lt;/h2&gt;

&lt;p&gt;One application holds a React product and a Blade public site. Nothing prevents the public homepage from shipping the application bundle. That is in fact what happens by default the moment a shared entry point looks convenient.&lt;/p&gt;

&lt;p&gt;So the Vite config declares two separate entries, and the comment next to them states the reason rather than the rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A marketing page must ship neither React, nor Inertia,&lt;/span&gt;
&lt;span class="c1"&gt;// nor the product bundle. Putting them in app.css/app.tsx&lt;/span&gt;
&lt;span class="c1"&gt;// would make the public homepage pay for the whole app.&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/css/app.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/js/app.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/css/marketing.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resources/js/marketing.ts&lt;/span&gt;&lt;span class="dl"&gt;'&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;Walking the production manifest, following the imports of the public entry and compressing each file, gives the payload of a page.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Raw&lt;/th&gt;
&lt;th&gt;Gzipped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;10,833 B&lt;/td&gt;
&lt;td&gt;4,323 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;73,931 B&lt;/td&gt;
&lt;td&gt;13,110 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;84,764 B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17,433 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A complete public page, navigation and interactions included, is 17.4 KB of compressed script and style. The JavaScript is compiled TypeScript with no framework, written for what those pages actually do.&lt;/p&gt;

&lt;p&gt;The separation is structural, not a convention someone has to remember. That distinction is the whole point: a rule written in a document decays, a rule written in the build does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content lives in Postgres, not in Markdown
&lt;/h2&gt;

&lt;p&gt;The usual path for a static site is Markdown files in the repository, rebuilt and redeployed on every edit, or a headless CMS: another service, another bill, another API to keep in sync, another authentication story.&lt;/p&gt;

&lt;p&gt;Our content hub is a set of Eloquent models in PostgreSQL, administered through a Filament panel. A non developer edits an article and it is live. No rebuild, no deploy, no content API, no webhook to invalidate anything. Laravel already has the model layer, the admin panel, the validation, the authorization and the localization; using them for content costs close to nothing.&lt;/p&gt;

&lt;p&gt;Putting content in the database rather than in files also has a consequence that took us a while to appreciate, and it is the most strategically interesting part of this architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The public site can read the session
&lt;/h2&gt;

&lt;p&gt;The marketing site runs inside the same Laravel application as the product. Same database, same session, same authentication, same authorization. Which means a public Blade page can know, server side, before a single byte of HTML is rendered: whether this visitor is signed in, which team they belong to, which plan they are on, whether they have ever run an audit, whether they abandoned onboarding halfway through.&lt;/p&gt;

&lt;p&gt;We do not do this yet. Our public views currently read no session state at all. But the capability is sitting there at zero additional infrastructure cost, and it is worth naming what category it belongs to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Personalizing a marketing site against product state is normally the territory of Adobe Experience Manager, Optimizely, and the personalization tier of a headless CMS. Those products largely exist to reconnect a static marketing site to state that lives somewhere else.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the site and the product are the same application, that reconnection is a function call. A static build plus a headless CMS cannot do it without standing up an API, shipping a client side fetch, and accepting a flash of the generic version before the personalized one arrives. We get the server rendered, no flicker, no extra request version for free, and we have not spent it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  One design system, two entries, no drift
&lt;/h2&gt;

&lt;p&gt;The two Vite entries are separate, but they are not independent. Both import the same token sheet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="nt"&gt;app&lt;/span&gt;&lt;span class="nc"&gt;.css&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;'./theme.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="nt"&gt;marketing&lt;/span&gt;&lt;span class="nc"&gt;.css&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;tokens&lt;/span&gt; &lt;span class="nt"&gt;come&lt;/span&gt; &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="nt"&gt;theme&lt;/span&gt;&lt;span class="nc"&gt;.css&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;shared&lt;/span&gt; &lt;span class="nt"&gt;with&lt;/span&gt; &lt;span class="nt"&gt;app&lt;/span&gt;&lt;span class="nc"&gt;.css&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s2"&gt;'./theme.css'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change a color, a radius or a spacing step, and the product and the public site both move, in the same build, in the same commit, verified by the same tests. There is no design system package to version and publish, no synchronization step between two repositories, no pair of Tailwind configs slowly drifting apart, and no marketing site that quietly starts looking like last year's product.&lt;/p&gt;

&lt;p&gt;This is not a claim that a separate front-end stack does this badly. It is a claim that it cannot do it this way. Two codebases means a publish step between them, and a publish step means a version, a changelog and a lag. One codebase means the lag is zero because there is nowhere for it to accumulate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tailwind with tokens
&lt;/h2&gt;

&lt;p&gt;The public stylesheet is 73,931 bytes raw and 13,110 compressed, covering all 26 views. That is simply what Tailwind gives when you do not work against it.&lt;/p&gt;

&lt;p&gt;In practice that means design tokens instead of arbitrary values. The palette lives in CSS variables, declared once, and the utilities consume them. A color hard coded into a view is a color that escapes the dark theme, escapes contrast auditing and escapes every future adjustment. Once the same surface has to exist in light and dark, the difference between the two approaches stops being aesthetic and becomes structural.&lt;/p&gt;

&lt;p&gt;It is also what makes the next section possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The score that lied
&lt;/h2&gt;

&lt;p&gt;Our first real Lighthouse campaign returned 100 for accessibility. It was wrong.&lt;/p&gt;

&lt;p&gt;The token used for every piece of secondary text on the site produced a contrast ratio of &lt;strong&gt;4.47 to 1&lt;/strong&gt; against the page canvas. The AA minimum is 4.5. We were three hundredths short, across every subtitle and every piece of metadata, on pages that discuss compliance, inside a product whose accessibility module grades our customers' sites.&lt;/p&gt;

&lt;p&gt;Three hundred and twenty feature tests could not see it, for a reason worth stating as it is.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A CSS file has no type checking. The TypeScript compiler does not read it, the static analyser does not read it, the formatter does not read it. Its tests are its only verification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was not the color. Fixing a color does nothing about the next one. The fix was a test that reads the shipped stylesheet from disk, follows variable aliases, composites semi transparent colors over their real background, then redoes the WCAG math.&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;$muted&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;themeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'--sp-ink-500'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;themeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'--sp-canvas'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$muted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$canvas&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$muted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'#ffffff'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties of that test matter more than the test itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reads the shipped baseline, never an injected value.&lt;/strong&gt; A test that hands itself the color it is about to measure only tests arithmetic. This one opens the file the browser will receive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It composites before measuring.&lt;/strong&gt; A semi transparent color has no contrast of its own; it only has one once it sits on a background. Measuring the nominal value would certify white at 20 percent opacity. That is the class of mistake that produces a green test and an unreadable page.&lt;/p&gt;

&lt;p&gt;The current ratio is 4.753. The threshold is a pinned floor rather than a target, so if someone lightens the canvas in six months, the test fails before production does.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same math settled a design argument
&lt;/h3&gt;

&lt;p&gt;On our deep blue surface, the green check mark that marks features included in a plan measures 2.921 to 1. On the dark theme accent it measures 4.29. Below the threshold on both.&lt;/p&gt;

&lt;p&gt;So the check mark is not green. It takes the text color, and a test pins that refusal, so nobody restores the green believing they are improving the screen. A brand decision settled by a calculation instead of an opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI asserts, and what it refuses to assert
&lt;/h2&gt;

&lt;p&gt;The Lighthouse CI budget for our public pages is three lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"categories:accessibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"categories:seo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"categories:performance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Accessibility and SEO are blocking assertions, required at 1.00. Performance is explicitly off.&lt;/p&gt;

&lt;p&gt;The budget runs a single pass, and performance is non deterministic: it depends on the load of the CI machine, on the network, on luck. A threshold on it produces red builds with no cause and green builds with no merit, and within three weeks the team learns to ignore the color. A guard that gets ignored is worse than no guard, because it leaves people believing they are covered.&lt;/p&gt;

&lt;p&gt;Accessibility and SEO are deterministic. The same files yield the same verdict, so they can be demanded at perfection, and they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that reframes the whole exercise
&lt;/h2&gt;

&lt;p&gt;Here is a performance trace of the site taken with a 20 times CPU slowdown and Slow 4G throttling, which is a deliberately brutal profile. The interesting column is main thread time by party.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Party&lt;/th&gt;
&lt;th&gt;Main thread&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wappalyzer (browser extension)&lt;/td&gt;
&lt;td&gt;690.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdBlock (browser extension)&lt;/td&gt;
&lt;td&gt;629.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fake Filler (browser extension)&lt;/td&gt;
&lt;td&gt;364.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React DevTools (browser extension)&lt;/td&gt;
&lt;td&gt;213.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The site itself, first party&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;206.7 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookieless analytics, our only third party&lt;/td&gt;
&lt;td&gt;23.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every single browser extension in that profile costs more main thread time than the entire website. Wappalyzer alone costs 3.3 times more. Our one third party script, cookieless analytics, costs 23.5 milliseconds.&lt;/p&gt;

&lt;p&gt;We find this genuinely useful rather than flattering. Once first party work is down in the low hundreds of milliseconds, the dominant term in a real visitor's experience is no longer your framework choice. It is their extensions, their device and their network. The engineering value of going from 4 KB to 2 KB is close to zero. The engineering value of the accessibility guard, which fixes something a visitor actually experiences, is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs we accepted
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page transitions are full loads.&lt;/strong&gt; No instant navigation, no hover prefetching unless we write it. Correct for a content site, wrong for an application, which is exactly why our product does not run this way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every interactive behaviour is hand written.&lt;/strong&gt; Menu, theme switcher, scroll reveals: TypeScript nobody maintains for us. A 4 KB budget is a constraint as much as a result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without a CDN, geography is real.&lt;/strong&gt; A visitor far from the origin pays the distance. Our audience is concentrated, so we take that trade, and it is a trade rather than a superiority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two worlds have to stay watertight&lt;/strong&gt;, and that boundary has a cost we paid in production. It is the subject of the next article.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we decide
&lt;/h2&gt;

&lt;p&gt;Three questions, asked of every piece we consider adding.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does this solve a problem we have, or a problem we might have?&lt;/li&gt;
&lt;li&gt;How many surfaces will we maintain because of it, and who maintains them?&lt;/li&gt;
&lt;li&gt;If it disappears during a version upgrade, is that a degradation or an outage?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answers gave us a public site in the same application as the product, with no intermediate layer and 4,323 bytes of JavaScript. Different answers give different architectures, and that is fine. What transfers is not the stack. It is measuring before deciding, then writing a guard so the decision outlives the people who made it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Next.&lt;/strong&gt; Three rendering worlds share this application: Blade for the public site, Inertia and React for the product, Filament for the admin console. We did not have to choose, and that was the right call. But the boundaries between those worlds cost something, and we shipped a defect to production that no feature test could ever have caught. That is &lt;a href="https://nessflow.com/en/engineering/blade-inertia-filament-on-purpose" rel="noopener noreferrer"&gt;the next article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Measurements: production manifest, 18 August 2026, gzip level 6 applied per file. PageSpeed mobile, 17 August 2026 19:51, lab, single URL, no field data. Chrome performance trace, 20x CPU throttling, Slow 4G. Contrast ratios computed with the WCAG 2.2 formula.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nessflow.com/en/engineering/modern-marketing-site-without-modern-frontend-stack" rel="noopener noreferrer"&gt;nessflow.com&lt;/a&gt;.&lt;br&gt;
I write about how NessFlow is built at &lt;a href="https://nessflow.com/en/engineering" rel="noopener noreferrer"&gt;nessflow.com/en/engineering&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>webdev</category>
      <category>performance</category>
      <category>php</category>
    </item>
  </channel>
</rss>
