<?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: Mohamed</title>
    <description>The latest articles on DEV Community by Mohamed (@mohamed_taha6608).</description>
    <link>https://dev.to/mohamed_taha6608</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%2F4112267%2F90bd7489-6712-4a6b-afb1-f3c7f834842a.jpg</url>
      <title>DEV Community: Mohamed</title>
      <link>https://dev.to/mohamed_taha6608</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohamed_taha6608"/>
    <language>en</language>
    <item>
      <title>A five-file Laravel release when the production worktree has drifted</title>
      <dc:creator>Mohamed</dc:creator>
      <pubDate>Mon, 12 Oct 2026 01:01:58 +0000</pubDate>
      <link>https://dev.to/mohamed_taha6608/a-five-file-laravel-release-when-the-production-worktree-has-drifted-ed2</link>
      <guid>https://dev.to/mohamed_taha6608/a-five-file-laravel-release-when-the-production-worktree-has-drifted-ed2</guid>
      <description>&lt;p&gt;On 5 October I shipped a design refresh to the storefront I run: one stylesheet and four Blade templates, the hero, catalog, operations and homepage views. Nothing else moved. No migrations, no seeding, no dependency changes, no database edits.&lt;/p&gt;

&lt;p&gt;The interesting part was not the design. It was that the production worktree was not in the state my branch said it should be in, and I did not want a whole-branch deployment to decide what those files became.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a normal git deploy
&lt;/h2&gt;

&lt;p&gt;The production checkout was at a different commit from my working copy. Its stylesheet carried a hash that I had pinned from an inspected snapshot, not derived from a commit. The production branch was left unchanged on purpose, because this was a scoped release over an existing production worktree.&lt;/p&gt;

&lt;p&gt;So the release became a list of five paths, each with a &lt;code&gt;before_sha256&lt;/code&gt; and an &lt;code&gt;after_sha256&lt;/code&gt;, written into a manifest. The local release script refuses to build the payload unless the four production templates match the committed versions in my source checkout and the stylesheet matches the hash I inspected. If either check fails, nothing is uploaded.&lt;/p&gt;

&lt;p&gt;The payload is a tar of exactly those five files, and the manifest records the production commit hash, the source commit hash, and the two digests per file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;apply&lt;/code&gt; checks before it writes anything
&lt;/h2&gt;

&lt;p&gt;The remote script takes one of three modes: &lt;code&gt;apply&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt; or &lt;code&gt;rollback&lt;/code&gt;. Everything runs inside an exclusive, non-blocking file lock, so a second release on the same checkout fails immediately instead of queueing. It is a file lock on one machine, which means it serialises releases on that host and nothing beyond it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;apply&lt;/code&gt; runs a chain of refusals before it touches a file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a &lt;code&gt;backup.json&lt;/code&gt; already exists in the release directory, the release was already attempted and the script stops; a retry needs a new release directory.&lt;/li&gt;
&lt;li&gt;Production &lt;code&gt;HEAD&lt;/code&gt; must equal the commit recorded in the manifest.&lt;/li&gt;
&lt;li&gt;Every target file's current hash must equal its &lt;code&gt;before_sha256&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every tar member must be a regular file named in the manifest; anything else aborts.&lt;/li&gt;
&lt;li&gt;The set of members must equal the set of manifest entries, or the payload is incomplete.&lt;/li&gt;
&lt;li&gt;Each member's content must hash to its &lt;code&gt;after_sha256&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Plain &lt;code&gt;.php&lt;/code&gt; files get &lt;code&gt;php -l&lt;/code&gt;; &lt;code&gt;.blade.php&lt;/code&gt; files are skipped. In this release the lint step did nothing, since the five files were Blade templates and a stylesheet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Target paths are also checked: no absolute paths, no &lt;code&gt;..&lt;/code&gt; segments, no symlinks anywhere between the root and the file.&lt;/p&gt;

&lt;p&gt;Only then does it back up each original together with its file mode, write &lt;code&gt;backup.json&lt;/code&gt;, and record the phase in &lt;code&gt;state.txt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write itself
&lt;/h2&gt;

&lt;p&gt;Each file is written through one small function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;atomic_write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mo"&gt;0o755&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;temporary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.storefront-release-tmp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;temporary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;xb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fileno&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temporary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temporary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;FileNotFoundError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;temporary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temp file lives in the same directory as the target so &lt;code&gt;os.replace&lt;/code&gt; is a rename on the same filesystem. A reader sees either the old file or the new one, never a half-written one.&lt;/p&gt;

&lt;p&gt;Right before each write, the script hashes the target again. The pre-flight check already passed, but the backup step and the write step are separate moments, and the error for this case is named for what it means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;before_sha256&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Concurrent production edit: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;atomic_write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the five writes it runs &lt;code&gt;php artisan config:cache&lt;/code&gt; and &lt;code&gt;php artisan view:cache&lt;/code&gt;, logging both to a file in the release directory and raising on a non-zero exit. As the Laravel deployment docs describe them, &lt;code&gt;config:cache&lt;/code&gt; combines the configuration files into a single cached file and &lt;code&gt;view:cache&lt;/code&gt; precompiles the Blade views so they are not compiled on demand.&lt;/p&gt;

&lt;p&gt;Then it hashes every file one more time and compares against &lt;code&gt;after_sha256&lt;/code&gt;. Only if that passes does it write &lt;code&gt;applied-awaiting-fpm-reload&lt;/code&gt; to &lt;code&gt;state.txt&lt;/code&gt; and print &lt;code&gt;reload php8.5-fpm&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback is guarded the same way
&lt;/h2&gt;

&lt;p&gt;Any exception inside the apply block triggers a rollback, and the exception is re-raised afterwards so the failure is still visible.&lt;/p&gt;

&lt;p&gt;Rollback refuses to overwrite a file whose current hash is neither the before hash nor the after hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;before_sha256&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;after_sha256&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Concurrent edit; refusing rollback overwrite: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule matters more than it looks. A rollback that blindly restores the backup would be a second way to lose a change made outside the release, which is the exact thing the whole release was built to avoid. Each backup is hashed against &lt;code&gt;before_sha256&lt;/code&gt; before it is restored, restored with the saved mode, and the caches are rebuilt again.&lt;/p&gt;

&lt;h2&gt;
  
  
  FPM reload is a separate step
&lt;/h2&gt;

&lt;p&gt;The remote script does not reload PHP-FPM. The local driver runs the reload after &lt;code&gt;apply&lt;/code&gt;, checks the service is active, and only then calls &lt;code&gt;verify&lt;/code&gt;, which re-hashes the five files against &lt;code&gt;after_sha256&lt;/code&gt;. The log for this release shows the backup, both cache rebuilds, the five applied files, &lt;code&gt;active&lt;/code&gt;, and &lt;code&gt;verified_files: 5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The release reloads FPM rather than waiting on timestamp validation. The PHP manual says that with &lt;code&gt;opcache.validate_timestamps&lt;/code&gt; disabled you must reset OPcache or restart the web server for changes to the filesystem to take effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read-only browser check
&lt;/h2&gt;

&lt;p&gt;Hash verification proves the bytes landed. It does not prove the pages render. So the last step is a Playwright script that is deliberately unable to submit anything.&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seosellers.com&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;www.seosellers.com&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;fonts.googleapis.com&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;fonts.gstatic.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;route&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request that is not GET or HEAD is aborted, and every request to a host other than the site or Google Fonts is aborted. Form submissions and third-party tracking cannot fire from inside the check.&lt;/p&gt;

&lt;p&gt;It visits 14 public routes at 1440px and 390px, which is 28 renders. Each one must return HTTP 200, contain exactly one &lt;code&gt;h1&lt;/code&gt;, have no horizontal overflow, no broken images, no uncaught page errors, and a &lt;code&gt;_token&lt;/code&gt; field on every POST form. Images are forced to eager loading and decoded, and fonts are awaited, before the measurements are taken.&lt;/p&gt;

&lt;p&gt;On the homepage it goes further: clicking the pages filter must leave exactly one visible product card, and at 390px the mobile menu must open and then close on Escape. Every result is written to a JSON file next to the screenshots. All 28 passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this release did not prove
&lt;/h2&gt;

&lt;p&gt;Live payment and mail delivery were not exercised. The git branch was not updated, so the next full deployment from the branch must carry these five changes or it will undo the design; that is a debt, and it is written in the release notes so it is not forgotten.&lt;/p&gt;

&lt;p&gt;The hashes pin a moment, not correctness. They prove the file on disk is the file I inspected, before and after. Whether that file is right is what the local tests, 50 of them with 382 assertions, and the 28 browser renders are for.&lt;/p&gt;

&lt;p&gt;The writes are atomic per file, not for the whole release. A request can see a mixture of old and new files while the five replacements are in progress.&lt;/p&gt;

&lt;p&gt;And the lock is per host. It serialises releases that go through this script on that machine. The hash checks detect changes present when they run, but an editor that ignores the lock can still write between a check and a replacement. The script reduces that risk; it does not eliminate the race.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build and run SEOSellers, a platform that publishes pages for other people's businesses; this release was its storefront.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>deployment</category>
      <category>python</category>
    </item>
    <item>
      <title>Laravel: the homepage worked, but every slug returned 404</title>
      <dc:creator>Mohamed</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:01:54 +0000</pubDate>
      <link>https://dev.to/mohamed_taha6608/laravel-the-homepage-worked-but-every-slug-returned-404-1fbh</link>
      <guid>https://dev.to/mohamed_taha6608/laravel-the-homepage-worked-but-every-slug-returned-404-1fbh</guid>
      <description>&lt;p&gt;The home page on a custom domain loaded. Every page at &lt;code&gt;/&amp;lt;slug&amp;gt;&lt;/code&gt; returned 404.&lt;/p&gt;

&lt;p&gt;The domain resolved, the root route worked, and the page records existed. Those symptoms can make a routing bug look like a missing-content problem.&lt;/p&gt;

&lt;p&gt;The route was this shape:&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;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'{customDomain}'&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="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="s1"&gt;'/{slug}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;LandingPageController&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;'customShow'&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 missing detail was in the controller signature. &lt;code&gt;{customDomain}&lt;/code&gt; is a route parameter too, and it arrives before &lt;code&gt;{slug}&lt;/code&gt;. A method shaped like this leaves no argument for the domain:&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;function&lt;/span&gt; &lt;span class="n"&gt;customShow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;string&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a request to &lt;code&gt;https://client.example/services&lt;/code&gt;, the route supplies &lt;code&gt;client.example&lt;/code&gt; and then &lt;code&gt;services&lt;/code&gt;. With that signature, &lt;code&gt;$slug&lt;/code&gt; receives &lt;code&gt;client.example&lt;/code&gt;. Looking up a page with the host as its slug returns nothing, so the request ends in a 404.&lt;/p&gt;

&lt;p&gt;The fix was to account for both parameters. Here's the relevant part of the method:&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;function&lt;/span&gt; &lt;span class="n"&gt;customShow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&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;string&lt;/span&gt; &lt;span class="nv"&gt;$customDomain&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;$slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;=&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;getHost&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="nc"&gt;LandingPage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'host'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&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;where&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="nv"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_published'&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Render the page or continue with the site's normal fallback.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The method reads the host from the request because that's the value it uses for the lookup. The &lt;code&gt;$customDomain&lt;/code&gt; argument still has to occupy its place so &lt;code&gt;$slug&lt;/code&gt; receives the path segment.&lt;/p&gt;

&lt;p&gt;What made this hard to notice was the passing home page. &lt;code&gt;/&lt;/code&gt; uses a different controller method and has no slug to misplace. It proved the domain was reachable, but it didn't prove nested pages worked.&lt;/p&gt;

&lt;p&gt;The regression test seeds a home page and a separate service page on the same host, then makes both requests:&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;$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;'http://poolco.example.test/'&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;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Welcome To The Pool Co Home'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&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;'http://poolco.example.test/deep-service-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;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;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The Deep Service Landing Page'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking the content matters too. A 200 response from a fallback page wouldn't prove that the requested slug reached the right page.&lt;/p&gt;

&lt;p&gt;When a Laravel route is bound to &lt;code&gt;Route::domain('{something}')&lt;/code&gt;, I now count the domain placeholder while matching route parameters to a controller signature. It is easy to forget because it isn't visible in the path.&lt;/p&gt;




&lt;p&gt;I build and run &lt;a href="https://seosellers.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto-custom-domain-route" rel="noopener noreferrer"&gt;SEOSellers&lt;/a&gt;. This issue came from its custom-domain page routing.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A logo at 1.00:1 contrast passed every check we had</title>
      <dc:creator>Mohamed</dc:creator>
      <pubDate>Fri, 18 Sep 2026 23:01:56 +0000</pubDate>
      <link>https://dev.to/mohamed_taha6608/a-logo-at-1001-contrast-passed-every-check-we-had-45ja</link>
      <guid>https://dev.to/mohamed_taha6608/a-logo-at-1001-contrast-passed-every-check-we-had-45ja</guid>
      <description>&lt;p&gt;I run a service that publishes landing pages for other people's brands. Every page&lt;br&gt;
gets the client's own logo in the header bar, and the bar comes in three colours:&lt;br&gt;
white, the brand colour, or a neutral dark.&lt;/p&gt;

&lt;p&gt;Picking the bar looked like the most trivial decision in the system. It produced&lt;br&gt;
a bug that is invisible to every signal you would normally trust. The page renders.&lt;br&gt;
The logo file is served. Every other gate is green. And half the client's name is&lt;br&gt;
not there.&lt;/p&gt;
&lt;h2&gt;
  
  
  The case that broke the naive version
&lt;/h2&gt;

&lt;p&gt;A client wordmark, transparent PNG: the word in white, a second word in the brand&lt;br&gt;
blue, hyphen between them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the &lt;strong&gt;brand-blue bar&lt;/strong&gt;, the blue word measured &lt;strong&gt;1.15:1&lt;/strong&gt;. Gone.&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;white bar&lt;/strong&gt;, the white word measured &lt;strong&gt;1.00:1&lt;/strong&gt;. Perfectly gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two bars, and each one erased a different half of the same logo. There was no&lt;br&gt;
correct answer in the set, which is how the neutral third bar got built.&lt;/p&gt;

&lt;p&gt;That is the first lesson: with two options it is easy to find one that scores&lt;br&gt;
acceptably &lt;em&gt;on average&lt;/em&gt;, and averages are exactly the wrong statistic here.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why average contrast is the wrong measurement
&lt;/h2&gt;

&lt;p&gt;The obvious implementation is: composite the logo over the bar, compute contrast&lt;br&gt;
per pixel, average it, pick the winner.&lt;/p&gt;

&lt;p&gt;It fails because it cannot tell these two situations apart:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A whole &lt;strong&gt;word&lt;/strong&gt; vanished.&lt;/li&gt;
&lt;li&gt;A faint interior &lt;strong&gt;highlight&lt;/strong&gt; faded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both remove a similar quantity of low-contrast ink. Only one is a defect. Average&lt;br&gt;
them into a single number and the word disappearing looks like a mild dip.&lt;/p&gt;

&lt;p&gt;The difference is not in &lt;em&gt;how much&lt;/em&gt; ink is lost, it is in &lt;strong&gt;where&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A word owns its horizontal slice of the mark — nothing else is in those columns.&lt;br&gt;
A lost interior detail shares its columns with ink that still reads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So you stop measuring pixels and start measuring &lt;strong&gt;columns&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The implementation
&lt;/h2&gt;

&lt;p&gt;Composite over the bar (alpha included — this is what the eye actually receives),&lt;br&gt;
then slice the mark's bounding box into full-height columns and ask which ones went&lt;br&gt;
dark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VIS_COLS&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;     &lt;span class="c1"&gt;# full-height column slices across the mark's bbox
&lt;/span&gt;&lt;span class="n"&gt;VIS_COL_MIN_INK&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.015&lt;/span&gt;  &lt;span class="c1"&gt;# a column holding &amp;lt;1.5% of total ink is noise, never judged
&lt;/span&gt;&lt;span class="n"&gt;VIS_COL_LOST&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;   &lt;span class="c1"&gt;# a judged column keeping &amp;lt;12% of its ink readable is a hole
&lt;/span&gt;&lt;span class="n"&gt;VIS_MAX_LOST_INK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;   &lt;span class="c1"&gt;# legible iff hole ink is &amp;lt;=15% of the mark...
&lt;/span&gt;&lt;span class="n"&gt;VIS_MIN_VISIBLE&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;   &lt;span class="c1"&gt;# ...and &amp;gt;=25% of all ink is CRISP overall
&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# What the eye gets: the pixel alpha-composited over the bar.
&lt;/span&gt;        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255.0&lt;/span&gt;
        &lt;span class="n"&gt;comp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&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="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&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="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;comp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;INK_MIN_CONTRAST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;VIS_READABLE_FLOOR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bucket that ink by column and flag the holes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lost_ink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
               &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;VIS_COL_MIN_INK&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;total_ink&lt;/span&gt;   &lt;span class="c1"&gt;# ignore noise columns
&lt;/span&gt;               &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;tot&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;VIS_COL_LOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# this column went dark
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whitespace never enters the calculation — a column with no ink simply isn't there.&lt;br&gt;
The &lt;code&gt;VIS_COL_MIN_INK&lt;/code&gt; floor handles the case that is left: a column holding a few&lt;br&gt;
stray anti-aliased pixels, where two of them fading would otherwise read as a hole.&lt;/p&gt;
&lt;h2&gt;
  
  
  WCAG 3:1 is the wrong floor here
&lt;/h2&gt;

&lt;p&gt;My first threshold was WCAG's 3.0:1 for non-text contrast. It rejected logos that&lt;br&gt;
were obviously fine on screen: a solid green icon at ~2.1:1, an orange one at&lt;br&gt;
~2.2:1. Both perfectly readable.&lt;/p&gt;

&lt;p&gt;WCAG's 3:1 is calibrated for &lt;strong&gt;thin UI strokes&lt;/strong&gt;. A chunky solid mark carries far&lt;br&gt;
more ink per glyph and survives well below it. So the floor is two-tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VIS_READABLE_FLOOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A column only counts as &lt;em&gt;lost&lt;/em&gt; when its ink cannot manage even 1.8:1 — truly gone&lt;br&gt;
(white-on-white at 1.00, blue-on-blue at 1.15), not merely muted. The separate&lt;br&gt;
"crisp" share still uses the strict threshold, so a muted mark scores low without&lt;br&gt;
being failed outright.&lt;/p&gt;

&lt;p&gt;Every threshold in that block is pinned by a calibration test against real rendered&lt;br&gt;
headers. When you are hand-tuning constants against human perception, the test suite&lt;br&gt;
is the only thing standing between you and re-breaking it in three months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opaque logos ask a completely different question
&lt;/h2&gt;

&lt;p&gt;This part is counter-intuitive.&lt;/p&gt;

&lt;p&gt;Everything above applies to &lt;strong&gt;transparent&lt;/strong&gt; logos, where the bar shows through and&lt;br&gt;
the question is &lt;em&gt;can I still read the ink&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;opaque&lt;/strong&gt; logo — one with its own baked-in background plate — is legible on&lt;br&gt;
every bar by construction. The ink question is meaningless. The real question is&lt;br&gt;
whether the bar &lt;strong&gt;matches the plate&lt;/strong&gt;, because if it does not, the logo renders as&lt;br&gt;
a visible pasted rectangle sitting on your header.&lt;/p&gt;

&lt;p&gt;Judging an opaque logo by ink contrast doesn't merely fail to help. It actively&lt;br&gt;
selects the wrong bar, because maximum ink contrast is often precisely the bar that&lt;br&gt;
clashes hardest with the plate.&lt;/p&gt;

&lt;p&gt;Three logo shapes, three different questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Logo&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transparent&lt;/td&gt;
&lt;td&gt;Does the ink survive the bar?&lt;/td&gt;
&lt;td&gt;A word disappears&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opaque&lt;/td&gt;
&lt;td&gt;Does the bar match the plate?&lt;/td&gt;
&lt;td&gt;A pasted rectangle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Text wordmark, safe by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The reason I keep coming back to this one: it is a whole category of bug where&lt;br&gt;
&lt;strong&gt;every automated signal is green and the output is still wrong&lt;/strong&gt;. The page renders,&lt;br&gt;
the logo file is served, every gate passes. The only detector is a human&lt;br&gt;
looking at the header — which does not scale past the first few dozen pages, and&lt;br&gt;
is exactly the step an unattended run skips.&lt;/p&gt;

&lt;p&gt;The fix is never "check it by eye." It is finding the measurement that actually&lt;br&gt;
encodes what your eye is doing, then pinning it with tests against ground truth you&lt;br&gt;
verified once, by hand, properly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://seosellers.com/free?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=devto-header-contrast" rel="noopener noreferrer"&gt;SEOSellers&lt;/a&gt;,&lt;br&gt;
where this check is one of eleven gates a page has to clear before it is&lt;br&gt;
allowed to publish. First page and article are free if you want to see the output.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>a11y</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
