<?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>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>
