<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="https://www.yellowduck.be/pretty-atom-feed-v3.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <link href="https://www.yellowduck.be" rel="alternate"/>
  <link href="https://www.yellowduck.be/posts/feed" rel="self"/>
  <author>
    <name>Pieter Claerhout</name>
    <email>pieter@yellowduck.be</email>
  </author>
  <id>https://www.yellowduck.be/posts/feed</id>
  <title>🐥 YellowDuck.be</title>
  <updated>2026-09-27T17:00:00Z</updated>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/id-design-and-primary-keys-pt-1" rel="alternate"/>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;Is your database design stunted by traditional approaches? Alexey Makhotkin delves into a refreshing take on primary keys and ID design. He highlights the importance of aligning primary key selection with business requirements rather than sticking to conventional database norms. Through engaging examples, he introduces the concept of external IDs and anchor IDs, demonstrating how they relate to database entities. Makhotkin also emphasizes the significance of immutable anchor IDs for unique identification, plus the nuances of using external IDs effectively, even those from outside systems. By analyzing a minimal content management scenario, he shows the logical modeling with integer primary keys and uniqueness constraints, setting a strong foundation for understanding primary keys in database design. Part two promises to further explore the complexities of composite primary keys, making this a must-read for database enthusiasts.&lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;&lt;a href=&quot;https://anchorsandlinks.com/posts/primary-keys/&quot;&gt;Continue reading on &lt;strong&gt;anchorsandlinks.com&lt;/strong&gt;&lt;/a&gt;&lt;p&gt;&lt;/p&gt;</content>
    <published>2026-09-27T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/id-design-and-primary-keys-pt-1</id>
    <title>🔗 ID design and primary keys, pt. 1</title>
    <updated>2026-09-27T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/natural-sorting-in-laravel-api-resources" rel="alternate"/>
    <content type="html">&lt;p&gt;When exposing related collections through Laravel API Resources, the default order is whatever the database returns — usually insertion order. For user-facing lists, that&apos;s rarely what you want.&lt;/p&gt;
&lt;h1&gt;The Problem&lt;/h1&gt;
&lt;p&gt;A resource with a &lt;code&gt;belongsToMany&lt;/code&gt; relation would return items in an arbitrary order:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;&amp;#39;items&amp;#39; =&gt; ItemResource::collection($this-&gt;whenLoaded(&amp;#39;items&amp;#39;)),&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Items named &quot;Item 2&quot; and &quot;Item 10&quot; would sort lexicographically: &lt;code&gt;Item 10&lt;/code&gt;, &lt;code&gt;Item 2&lt;/code&gt; — not what a user expects.&lt;/p&gt;
&lt;h1&gt;The Fix&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;whenLoaded&lt;/code&gt; accepts a callback that runs only when the relation is loaded. Use it to sort the collection before handing it off to the resource:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;&amp;#39;items&amp;#39; =&gt; ItemResource::collection(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    $this-&gt;whenLoaded(&amp;#39;items&amp;#39;, fn () =&gt; $this-&gt;items-&gt;sortBy(&amp;#39;name&amp;#39;, SORT_NATURAL | SORT_FLAG_CASE))
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;),&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SORT_NATURAL&lt;/code&gt; gives you human-friendly ordering (&lt;code&gt;Item 1&lt;/code&gt;, &lt;code&gt;Item 2&lt;/code&gt;, &lt;code&gt;Item 10&lt;/code&gt;). &lt;code&gt;SORT_FLAG_CASE&lt;/code&gt; makes it case-insensitive. The callback is only invoked when the relation is loaded, so lazy-loading is never triggered accidentally.&lt;/p&gt;
&lt;h1&gt;Testing It&lt;/h1&gt;
&lt;p&gt;The key assertion is that numeric suffixes sort numerically, not lexicographically:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;$item10 = Item::factory()-&gt;create([&amp;#39;name&amp;#39; =&gt; &amp;#39;Item 10&amp;#39;]);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;$item2  = Item::factory()-&gt;create([&amp;#39;name&amp;#39; =&gt; &amp;#39;Item 2&amp;#39;]);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;$item1  = Item::factory()-&gt;create([&amp;#39;name&amp;#39; =&gt; &amp;#39;Item 1&amp;#39;]);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;$model-&gt;items()-&gt;attach([$item10-&gt;id, $item2-&gt;id, $item1-&gt;id]);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;$model-&gt;load(&amp;#39;items&amp;#39;);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;$result = (new ModelResource($model))-&gt;toArray(Request::create(&amp;#39;/&amp;#39;));
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;$names = collect($result[&amp;#39;items&amp;#39;])-&gt;pluck(&amp;#39;name&amp;#39;)-&gt;values()-&gt;all();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;$this-&gt;assertSame([&amp;#39;Item 1&amp;#39;, &amp;#39;Item 2&amp;#39;, &amp;#39;Item 10&amp;#39;], $names);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Attach them out of order, load, assert the right order comes out. Simple and robust.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/php&quot;&gt;#php&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/http&quot;&gt;#http&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/testing&quot;&gt;#testing&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-09-16T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/natural-sorting-in-laravel-api-resources</id>
    <title>🐥 Natural sorting in Laravel API resources</title>
    <updated>2026-09-16T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/undoing-a-pushed-merge-commit-without-losing-work" rel="alternate"/>
    <content type="html">&lt;p&gt;Sometimes you push a merge commit you didn&apos;t mean to, and notice it only after the fact. Here&apos;s how we handled it cleanly when that happened on a feature branch.&lt;/p&gt;
&lt;p&gt;My branch history looked like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;abc1234  Fix for the feature we were working on  ← latest commit, must keep
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;def5678  Merge branch &amp;#39;feature/my-branch&amp;#39; of ...  ← mistake
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;ghi9012  Previous commit
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;jkl3456  Older commit&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The merge commit &lt;code&gt;def5678&lt;/code&gt; was already pushed to GitHub. The latest fix (&lt;code&gt;abc1234&lt;/code&gt;) sat on top of it, so a plain &lt;code&gt;git reset --hard HEAD~1&lt;/code&gt; would have taken the fix with it.&lt;/p&gt;
&lt;p&gt;I needed to drop exactly one commit from the middle of the history while keeping everything above it. &lt;code&gt;git rebase --onto&lt;/code&gt; is the right tool for this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;git rebase --onto ghi9012 def5678 HEAD&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells Git: take all commits after &lt;code&gt;def5678&lt;/code&gt; up to &lt;code&gt;HEAD&lt;/code&gt;, and replay them directly onto &lt;code&gt;ghi9012&lt;/code&gt; — the commit before the bad merge. The merge commit is skipped entirely.&lt;/p&gt;
&lt;p&gt;The rebase left us in a detached HEAD state, so we updated the branch pointer and switched back:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;git branch -f feature/my-branch HEAD
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;git checkout feature/my-branch&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The history was now clean:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;xyz7890  Fix for the feature we were working on
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;ghi9012  Previous commit
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;jkl3456  Older commit&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then a force-push to update the remote:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;git push --force&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why not &lt;code&gt;git revert&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;git revert -m 1 &lt;hash&gt;&lt;/code&gt; is the safer choice when multiple people have already pulled the branch, because it adds a new commit rather than rewriting history. In our case the branch was a personal feature branch with no other active collaborators, so rewriting was fine and kept the history cleaner.&lt;/p&gt;
&lt;p&gt;When in doubt on a shared branch, revert. On a solo feature branch, rebase is cleaner.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/terminal&quot;&gt;#terminal&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/git&quot;&gt;#git&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-09-12T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/undoing-a-pushed-merge-commit-without-losing-work</id>
    <title>🐥 Undoing a pushed merge commit without losing work</title>
    <updated>2026-09-12T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/laravel-boost-best-practices-a-major-tone-shift-august-2026" rel="alternate"/>
    <content type="html">&lt;p&gt;Two commits landed in the &lt;a href=&quot;https://github.com/laravel/boost&quot;&gt;Laravel Boost&lt;/a&gt; repository on August 26, 2026 that significantly update the AI guidance rules baked into the package. The changes touch all 19 best-practice rule files and add a new section on controller resource design. Here is what changed and why it matters.&lt;/p&gt;
&lt;h1&gt;The big picture: from prescriptive to contextual&lt;/h1&gt;
&lt;p&gt;The first commit (&lt;a href=&quot;https://github.com/laravel/boost/commit/d165b9ea6086294a8f1999ac3dcf4e61da267b23&quot;&gt;&lt;code&gt;d165b9ea&lt;/code&gt;&lt;/a&gt;) touches 1,427 lines across every rule file. The headline change is not a new feature — it is a &lt;strong&gt;deliberate softening of absolute language&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Rules that previously said &lt;strong&gt;&quot;Incorrect&quot; / &quot;Correct&quot;&lt;/strong&gt; now use labels like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&quot;Manual lookup:&quot;&lt;/em&gt; / &lt;em&gt;&quot;Use route model binding:&quot;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&quot;Hidden dependency:&quot;&lt;/em&gt; / &lt;em&gt;&quot;Injected dependency:&quot;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&quot;Unsafe:&quot;&lt;/em&gt; / &lt;em&gt;&quot;Preferred:&quot;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&quot;Queued and durable:&quot;&lt;/em&gt; / &lt;em&gt;&quot;Deferred in the current process:&quot;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The intent is clear: the guidance now presents &lt;strong&gt;trade-offs and context&lt;/strong&gt; instead of moral verdicts. This matters because AI coding agents (and developers) following these rules were previously being trained to treat &quot;Incorrect&quot; examples as always wrong, even in situations where the pattern was actually fine.&lt;/p&gt;
&lt;h1&gt;Key Changes by Area&lt;/h1&gt;
&lt;h2&gt;Queries — &lt;code&gt;advanced-queries.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The old guidance said &lt;code&gt;whereHas()&lt;/code&gt; was flat-out wrong because it &quot;re-executes per row.&quot; The new version is more honest:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;whereHas()&lt;/code&gt; typically produces an &lt;code&gt;EXISTS&lt;/code&gt; subquery, while &lt;code&gt;whereIn()&lt;/code&gt; can express the same filter with an &lt;code&gt;IN&lt;/code&gt; subquery. Either form may be faster depending on the database engine, indexes, cardinality, and query plan. &lt;strong&gt;Measure both forms with representative data.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The section title changed from &lt;em&gt;&quot;Prefer &lt;code&gt;whereIn&lt;/code&gt; + Subquery Over &lt;code&gt;whereHas&lt;/code&gt;&quot;&lt;/em&gt; to &lt;em&gt;&quot;Compare &lt;code&gt;whereHas()&lt;/code&gt; with an &lt;code&gt;IN&lt;/code&gt; Subquery&quot;&lt;/em&gt;. Similarly, the compound index rule now says &lt;em&gt;&quot;verify the query plan&quot;&lt;/em&gt; rather than asserting a single correct approach.&lt;/p&gt;
&lt;p&gt;A technical fix also snuck in: the dynamic relationship example now correctly passes the foreign key to &lt;code&gt;belongsTo()&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;// Before (broken — missing the FK argument):
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;return $this-&gt;belongsTo(Login::class);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;// After (correct):
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;return $this-&gt;belongsTo(Login::class, &amp;#39;last_login_id&amp;#39;);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Architecture — &lt;code&gt;architecture.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Two notable shifts:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dependency injection&lt;/strong&gt;: The old rule said constructor injection was always correct and method injection was a code smell. The new rule aligns with how Laravel actually works:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Prefer constructor injection for dependencies required throughout an object&apos;s lifetime. &lt;strong&gt;Method injection is appropriate for dependencies needed by one controller action, listener, job handler, or other container-invoked method.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Default sort order&lt;/strong&gt;: Instead of &quot;always use &lt;code&gt;latest()&lt;/code&gt;&quot;, the guidance now recommends a stable two-column sort with a tie-breaker for reliable pagination:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;Post::query()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    -&gt;orderByDesc(&amp;#39;created_at&amp;#39;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    -&gt;orderByDesc(&amp;#39;id&amp;#39;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    -&gt;paginate();&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Caching — &lt;code&gt;caching.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;Cache::remember()&lt;/code&gt; section now explicitly warns about the false-cache-miss bug:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The manual version below incorrectly treats valid falsy values, such as &lt;code&gt;false&lt;/code&gt; or &lt;code&gt;0&lt;/code&gt;, as cache misses.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It also adds an honest caveat: &lt;code&gt;Cache::remember()&lt;/code&gt; does not prevent concurrent requests from computing the same missing value — you still need &lt;code&gt;Cache::lock()&lt;/code&gt; for that.&lt;/p&gt;
&lt;h2&gt;Routing — &lt;code&gt;routing.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The &quot;Keep Controllers Thin&quot; rule (with its arbitrary &quot;under 10 lines&quot; target) is gone. In its place:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Controllers should coordinate HTTP input, authorization, validation, an application operation, and the response. &lt;strong&gt;Extract substantial or reusable business logic, but do not introduce an action or service merely to satisfy an arbitrary line limit.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Migrations — &lt;code&gt;migrations.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The file shrank significantly (78 → 24 deletions vs. 24 additions). Several overly prescriptive examples were removed. The foreign-key section now includes a practical note:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Do not add a duplicate single-column index without checking the database driver&apos;s treatment of foreign-key indexes and the indexes already created by the migration.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Security — &lt;code&gt;security.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Two nuanced additions stand out:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;$guarded = []&lt;/code&gt; is no longer labeled universally &quot;Incorrect.&quot; The new text acknowledges that mass-assignment protection controls &lt;em&gt;which attributes can be set&lt;/em&gt;, not values or authorization — and that deliberate conventions using &lt;code&gt;$guarded&lt;/code&gt; are valid.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The SQL injection example now uses the fluent string helper properly:&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;User::whereRaw(&amp;#39;LOWER(name) = ?&amp;#39;, [$request-&gt;string(&amp;#39;name&amp;#39;)-&gt;lower()-&gt;toString()])-&gt;get();&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;A new explicit note: &lt;em&gt;&quot;Public actions intentionally available to everyone do not need a redundant authorization check.&quot;&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Queues — &lt;code&gt;queue-jobs.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The Amazon SQS footnote is new and important:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Amazon Simple Queue Service uses its &lt;strong&gt;visibility timeout&lt;/strong&gt; instead of Laravel&apos;s &lt;code&gt;retry_after&lt;/code&gt;; configure that timeout at the queue level.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The &lt;code&gt;ShouldBeUnique&lt;/code&gt; section now notes that all dispatching processes must share a cache store that supports locks — a silent gotcha that has burned teams before.&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;New section: organize controllers around resources&lt;/h1&gt;
&lt;p&gt;The second commit (&lt;a href=&quot;https://github.com/laravel/boost/commit/d165b9ea6086294a8f1999ac3dcf4e61da267b23&quot;&gt;&lt;code&gt;b19e98a8&lt;/code&gt;&lt;/a&gt;) adds a standalone section to &lt;code&gt;routing.md&lt;/code&gt; covering a pattern that often gets debated on Laravel teams.&lt;/p&gt;
&lt;p&gt;The guidance establishes a clear default: organize controllers around one resource using standard resource actions (&lt;code&gt;index&lt;/code&gt;, &lt;code&gt;show&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;store&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;destroy&lt;/code&gt;). When a custom verb like &lt;code&gt;publish&lt;/code&gt;, &lt;code&gt;approve&lt;/code&gt;, or &lt;code&gt;archive&lt;/code&gt; appears, treat it as a &lt;strong&gt;design signal&lt;/strong&gt; — it might represent a separate resource.&lt;/p&gt;
&lt;p&gt;The canonical example models podcast publishing as a dedicated controller:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;// Route
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;Route::post(&amp;#39;/published-podcasts/{podcast}&amp;#39;, [PublishedPodcastController::class, &amp;#39;store&amp;#39;])
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    -&gt;name(&amp;#39;published-podcasts.store&amp;#39;);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;Route::delete(&amp;#39;/published-podcasts/{podcast}&amp;#39;, [PublishedPodcastController::class, &amp;#39;destroy&amp;#39;])
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    -&gt;name(&amp;#39;published-podcasts.destroy&amp;#39;);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;// Controller
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;class PublishedPodcastController extends Controller
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    public function store(Podcast $podcast): RedirectResponse
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;        $podcast-&gt;publish();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;        return back();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;    }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;    public function destroy(Podcast $podcast): RedirectResponse
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;    {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;        $podcast-&gt;unpublish();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;        return back();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;    }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The guidance ends with an important escape hatch:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Treat a custom verb as a &lt;strong&gt;design signal, not proof&lt;/strong&gt; that another controller is required. Use query parameters for simple filtering, and keep an explicit action route when modeling the operation as a resource would obscure the domain or conflict with established project conventions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Why this matters for AI-assisted development&lt;/h2&gt;
&lt;p&gt;Laravel Boost feeds these rules directly to AI coding agents as context when generating code. Overly prescriptive &quot;always/never&quot; rules cause agents to reject valid patterns and to add unnecessary abstractions (action classes for trivial logic, for example). The new tone should lead to more pragmatic, context-aware suggestions — and fewer unnecessary &quot;Incorrect&quot; rewrites of perfectly reasonable code.&lt;/p&gt;
&lt;p&gt;The shift also signals a broader direction: good AI guidance reads like a &lt;strong&gt;senior developer explaining trade-offs&lt;/strong&gt;, not a linter listing violations.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/ai&quot;&gt;#ai&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-09-07T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/laravel-boost-best-practices-a-major-tone-shift-august-2026</id>
    <title>🐥 Laravel boost best practices: A major tone shift (August 2026)</title>
    <updated>2026-09-07T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/one-line-to-measure-php-memory" rel="alternate"/>
    <content type="html">&lt;p&gt;You&apos;ve just run a long-running Artisan command and production is complaining about memory. Before reaching for Xdebug or a full profiler, there&apos;s a tool already on your machine that answers the question in one line:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;/usr/bin/time -l php artisan your:command 2&gt;&amp;1 | grep &quot;maximum resident&quot;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it. No code changes, no instrumentation, no deployment.&lt;/p&gt;
&lt;h2&gt;Breaking it down&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/usr/bin/time&lt;/code&gt;&lt;/strong&gt; — the system binary, not the shell built-in. The full path is important: your shell likely has a &lt;code&gt;time&lt;/code&gt; built-in that doesn&apos;t support the &lt;code&gt;-l&lt;/code&gt; flag.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;-l&lt;/code&gt;&lt;/strong&gt; — tells it to emit detailed resource usage. On Linux, use &lt;code&gt;-v&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;2&gt;&amp;1&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;time&lt;/code&gt; writes its output to stderr, so you need to redirect it into stdout before you can pipe it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;| grep &quot;maximum resident&quot;&lt;/code&gt;&lt;/strong&gt; — filters down to the one line you care about.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The output looks like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt; 147922944  maximum resident set size&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That number is in bytes. Divide by &lt;code&gt;1024 × 1024&lt;/code&gt; and you have the peak RSS in megabytes — in this case, &lt;strong&gt;141 MB&lt;/strong&gt;. That&apos;s the high-water mark of physical RAM the process held at any one moment, and the right number to compare against &lt;code&gt;memory_limit&lt;/code&gt; in &lt;code&gt;php.ini&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Why not &lt;code&gt;memory_get_peak_usage()&lt;/code&gt;?&lt;/h2&gt;
&lt;p&gt;PHP&apos;s own &lt;code&gt;memory_get_peak_usage(true)&lt;/code&gt; is great when you control the code — add it at the end of &lt;code&gt;handle()&lt;/code&gt; and you see PHP&apos;s internal allocator peak. But it only sees what PHP&apos;s memory manager tracked. The OS-level approach catches everything: the runtime itself, loaded extensions, forked child processes. For commands that spawn subprocesses or load large extensions, the two numbers can diverge meaningfully.&lt;/p&gt;
&lt;p&gt;The OS measurement also requires no deployment — run it against production code on a staging server without touching a line.&lt;/p&gt;
&lt;h2&gt;macOS vs. Linux&lt;/h2&gt;
&lt;p&gt;Swap &lt;code&gt;-l&lt;/code&gt; for &lt;code&gt;-v&lt;/code&gt; on Linux. The field name also changes slightly — look for &lt;code&gt;&quot;Maximum resident set size (kbytes)&quot;&lt;/code&gt; — and note the unit shift from bytes to kilobytes.&lt;/p&gt;
&lt;h2&gt;Drop the grep for more&lt;/h2&gt;
&lt;p&gt;Skip the pipe entirely and &lt;code&gt;/usr/bin/time -l&lt;/code&gt; dumps the full resource table after your command finishes: page faults, context switches, I/O operations, wall time. Worth reading once to understand what your command is actually doing at the OS level.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/php&quot;&gt;#php&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/terminal&quot;&gt;#terminal&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/sysadmin&quot;&gt;#sysadmin&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-09-05T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/one-line-to-measure-php-memory</id>
    <title>🐥 One line to measure PHP memory</title>
    <updated>2026-09-05T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/customizing-the-laravel-boost-guidelines" rel="alternate"/>
    <content type="html">&lt;p&gt;In my current project, I&apos;m using &lt;a href=&quot;https://laravel.com/ai/boost&quot;&gt;Laravel Boost&lt;/a&gt; to injects contextual guidelines into your AI coding assistant (Claude Code, Cursor, Copilot, etc.) by composing a set of named rule blocks — &lt;code&gt;foundation&lt;/code&gt;, &lt;code&gt;boost&lt;/code&gt;, &lt;code&gt;php&lt;/code&gt;, &lt;code&gt;laravel/core&lt;/code&gt;, &lt;code&gt;deployments&lt;/code&gt;, and version-specific blocks like &lt;code&gt;laravel/v13&lt;/code&gt; — into the &lt;code&gt;CLAUDE.md&lt;/code&gt; file at the root of your project.&lt;/p&gt;
&lt;p&gt;One of the things that annoyed me is that &lt;a href=&quot;https://github.com/laravel/boost/pull/758&quot;&gt;this PR&lt;/a&gt; added marketing info from Laravel promoting their Laravel Cloud solution for deployment. As I&apos;m not using it (and have no intentions to do so), after each update of the Boost guidelines, I needed to manually remove this part:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-markdown&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;# Deployment
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It turns out there is another PR that offers a solution for this, &lt;a href=&quot;https://github.com/laravel/boost/pull/774&quot;&gt;PR #774&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It adds a small but useful quality-of-life improvement: the deployment guideline has been extracted from &lt;code&gt;laravel/core&lt;/code&gt; into its own named block (&lt;code&gt;deployments&lt;/code&gt;), and you can now exclude any named guideline block from being injected via a config option.&lt;/p&gt;
&lt;p&gt;To exclude these guidelines, first start with publishing the Boost config if you haven&apos;t already:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;php artisan vendor:publish --tag=boost-config&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then add the &lt;code&gt;guidelines&lt;/code&gt; key to &lt;code&gt;config/boost.php&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;return [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    // ...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    &amp;#39;guidelines&amp;#39; =&gt; [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        &amp;#39;exclude&amp;#39; =&gt; [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;            &amp;#39;deployments&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;        ],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    ],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;];&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the next &lt;code&gt;boost:update&lt;/code&gt; run, the &lt;code&gt;=== deployments rules ===&lt;/code&gt; block will no longer appear in your &lt;code&gt;CLAUDE.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;AI coding assistants consume every line of your guidelines as context on every request. Irrelevant rules waste tokens and can subtly steer the model toward unhelpful suggestions. If your team deploys to a custom pipeline, Kubernetes, or anything other than Laravel Cloud, the default deployment hint is pure noise — and now you can cleanly remove it without patching vendor files.&lt;/p&gt;
&lt;p&gt;The same &lt;code&gt;exclude&lt;/code&gt; mechanism works for any named guideline block, so as Boost gains more optional sections (versioned package rules, framework-specific hints), you&apos;ll be able to keep your injected context lean and project-relevant.&lt;/p&gt;
&lt;p&gt;If you want to keep deployment guidance but tailor it to your stack, the PR also documents the override path: create &lt;code&gt;.ai/deployments/core.blade.php&lt;/code&gt; in your project root and write your own instructions there. Boost picks up local overrides before falling back to its own templates, so your custom block replaces the default without needing the &lt;code&gt;exclude&lt;/code&gt; config at all.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/php&quot;&gt;#php&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/ai&quot;&gt;#ai&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/devops&quot;&gt;#devops&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-09-02T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/customizing-the-laravel-boost-guidelines</id>
    <title>🐥 Customizing the Laravel Boost guidelines</title>
    <updated>2026-09-02T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/building-a-laravel-style-event-system-in-elixir-phoenix" rel="alternate"/>
    <content type="html">&lt;p&gt;Laravel&apos;s events and listeners are a fantastic way to decouple your application. You dispatch an event, register one or more listeners, and each listener reacts independently. It&apos;s a simple pattern that keeps your code clean.&lt;/p&gt;
&lt;p&gt;Phoenix doesn&apos;t provide an equivalent abstraction out of the box, but OTP and Phoenix PubSub make it straightforward to build something similar while remaining idiomatic.&lt;/p&gt;
&lt;h1&gt;Publishing domain events&lt;/h1&gt;
&lt;p&gt;Rather than having models emit events automatically, publish events from your contexts after a successful database operation.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;def update_user(user, attrs) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  old = user
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  with {:ok, user} &lt;- User.changeset(user, attrs) |&gt; Repo.update() do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    Events.publish(%Events.UserUpdated{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;      old: old,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;      new: user,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;      actor_id: attrs[:actor_id]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    })
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    {:ok, user}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that the event describes a business action (&lt;code&gt;UserUpdated&lt;/code&gt;) rather than a database operation.&lt;/p&gt;
&lt;h1&gt;Creating an event bus&lt;/h1&gt;
&lt;p&gt;A small wrapper around Phoenix PubSub provides a central place for publishing events.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;defmodule MyApp.Events do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  @topic &quot;events&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  def publish(event) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    Phoenix.PubSub.broadcast(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;      MyApp.PubSub,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;      @topic,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;      {:event, event}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    )
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you an API similar to Laravel&apos;s &lt;code&gt;event(...)&lt;/code&gt; helper.&lt;/p&gt;
&lt;h1&gt;Listening for events&lt;/h1&gt;
&lt;p&gt;Listeners are simply GenServers that subscribe to your event topic.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;defmodule MyApp.Audit.Listener do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  use GenServer
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  def start_link(_) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    GenServer.start_link(__MODULE__, %{})
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;  def init(state) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    Phoenix.PubSub.subscribe(MyApp.PubSub, &quot;events&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    {:ok, state}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;  def handle_info({:event, event}, state) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    handle_event(event)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;    {:noreply, state}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;  defp handle_event(%Events.UserUpdated{} = event) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;    Audit.log(event)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;  defp handle_event(_), do: :ok
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Adding additional listeners for emails, metrics, search indexing or webhooks is simply a matter of starting another GenServer.&lt;/p&gt;
&lt;h1&gt;Is this suitable for audit logging?&lt;/h1&gt;
&lt;p&gt;Not quite.&lt;/p&gt;
&lt;p&gt;Phoenix PubSub is excellent for notifying other parts of your application, but it does not guarantee that a listener has successfully processed an event.&lt;/p&gt;
&lt;p&gt;Imagine the following sequence:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;Repo.update(...)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;↓
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;Publish event
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;↓
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;Application crashes&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database change has been committed, but the audit record may never be written.&lt;/p&gt;
&lt;p&gt;For an audit trail, that&apos;s usually unacceptable.&lt;/p&gt;
&lt;h1&gt;Use Ecto.Multi for audit logging&lt;/h1&gt;
&lt;p&gt;A more robust approach is to store the audit record in the same transaction as the database change.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;Ecto.Multi.new()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;|&gt; Ecto.Multi.update(:user, changeset)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;|&gt; Ecto.Multi.insert(:audit_log, fn %{user: user} -&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  AuditLog.changeset(%AuditLog{}, %{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    entity: &quot;user&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    entity_id: user.id,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    action: &quot;updated&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;  })
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;end)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;|&gt; Repo.transaction()&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now either both records are committed, or neither is.&lt;/p&gt;
&lt;h1&gt;Capturing the changes&lt;/h1&gt;
&lt;p&gt;You don&apos;t need to manually compare two structs. Ecto already tracks modified fields through the changeset.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;changeset.changes&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For more detailed audit logs, you can combine the original struct with the updated one to produce a before/after representation.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-json&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  &quot;name&quot;: {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    &quot;old&quot;: &quot;John&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    &quot;new&quot;: &quot;Johnny&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  },
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  &quot;email&quot;: {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    &quot;old&quot;: &quot;john@example.com&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    &quot;new&quot;: &quot;johnny@example.com&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;  }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes it easy to display exactly what changed in an audit history.&lt;/p&gt;
&lt;h1&gt;Putting it together&lt;/h1&gt;
&lt;p&gt;A pattern that has worked well for larger Phoenix applications is to separate business events from auditing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Publish domain events with Phoenix PubSub for emails, search indexing, webhooks and other asynchronous work.&lt;/li&gt;
&lt;li&gt;Persist audit logs inside the same database transaction using &lt;code&gt;Ecto.Multi&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Offload expensive work to Oban jobs instead of executing it directly from listeners.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This gives you the same loose coupling as Laravel&apos;s event system while embracing Elixir&apos;s strengths: explicit processes, OTP supervision and reliable transactional guarantees.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/pattern&quot;&gt;#pattern&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/database&quot;&gt;#database&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/elixir&quot;&gt;#elixir&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/phoenix&quot;&gt;#phoenix&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-31T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/building-a-laravel-style-event-system-in-elixir-phoenix</id>
    <title>🐥 Building a Laravel-style event system in Elixir Phoenix</title>
    <updated>2026-08-31T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/chaining-github-actions-workflows-the-right-way" rel="alternate"/>
    <content type="html">&lt;p&gt;If you&apos;ve spent any time maintaining CI/CD pipelines, you&apos;ve probably run into this situation: a workflow that deploys your app, and some follow-up work that should happen right after — posting a changelog, syncing release notes to a project tracker, notifying a downstream system. The naive solution is to pile everything into the same workflow file. It works, but it doesn&apos;t stay clean for long.&lt;/p&gt;
&lt;p&gt;A better option is to split the follow-up into its own workflow file and call it from the deployment workflow when needed. GitHub Actions supports this natively through reusable workflows, and once you understand how the wiring works, it&apos;s surprisingly straightforward.&lt;/p&gt;
&lt;p&gt;GitHub Actions has a trigger type called &lt;code&gt;workflow_call&lt;/code&gt;. When you add it to a workflow&apos;s &lt;code&gt;on:&lt;/code&gt; block, that workflow becomes callable by other workflows in the same repository — similar to importing a function from another module. The calling workflow references it as a job using &lt;code&gt;uses:&lt;/code&gt; with a relative path, instead of a &lt;code&gt;runs-on&lt;/code&gt; + &lt;code&gt;steps&lt;/code&gt; structure.&lt;/p&gt;
&lt;p&gt;The pattern looks like this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The reusable workflow&lt;/strong&gt; (&lt;code&gt;release-notes.yaml&lt;/code&gt;):&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;on:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  workflow_call:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  workflow_dispatch:  # keep this so you can still trigger it manually&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The caller workflow&lt;/strong&gt; (&lt;code&gt;deploy.yaml&lt;/code&gt;):&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;jobs:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  deploy:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    runs-on: ubuntu-latest
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    steps:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;      - name: Deploy
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;        run: ./deploy.sh
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;  post-release-notes:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    needs: [deploy]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    uses: ./.github/workflows/release-notes.yaml
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    secrets: inherit&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the whole thing. When the &lt;code&gt;deploy&lt;/code&gt; job finishes, &lt;code&gt;post-release-notes&lt;/code&gt; kicks off by running the full &lt;code&gt;release-notes.yaml&lt;/code&gt; workflow.&lt;/p&gt;
&lt;p&gt;A few things worth knowing&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Reusable workflows are jobs, not steps.&lt;/strong&gt; This is a constraint that trips people up. You cannot nest a &lt;code&gt;uses:&lt;/code&gt; directive inside a job&apos;s &lt;code&gt;steps:&lt;/code&gt; block. It has to be a top-level job. If you need to call a reusable workflow conditionally (e.g., only on pushes to &lt;code&gt;main&lt;/code&gt;), you do that with an &lt;code&gt;if:&lt;/code&gt; on the job itself, not inside the workflow being called.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;secrets: inherit&lt;/code&gt; does what you think.&lt;/strong&gt; Without it, the called workflow has no access to secrets. You&apos;d have to pass them explicitly via &lt;code&gt;with:&lt;/code&gt; inputs, which is verbose. Using &lt;code&gt;secrets: inherit&lt;/code&gt; passes the calling workflow&apos;s secrets through automatically, which is almost always what you want for internal reuse within a single repo.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The called workflow can still have its own triggers.&lt;/strong&gt; Adding &lt;code&gt;workflow_call&lt;/code&gt; doesn&apos;t replace whatever triggers were already there. A workflow that was previously only &lt;code&gt;schedule&lt;/code&gt;-triggered can become reusable without breaking the scheduled runs. This is handy during a migration — you can add &lt;code&gt;workflow_call&lt;/code&gt; first, wire up the caller, and verify everything works before removing any manual triggers.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Conditional execution on the caller side.&lt;/strong&gt; When you have separate deploy jobs for different environments (staging vs. production), you&apos;ll often want separate caller jobs too, each with its own &lt;code&gt;needs:&lt;/code&gt; and &lt;code&gt;if:&lt;/code&gt; condition:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;  post-release-notes-staging:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;    needs: [deploy-staging]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    if: github.ref == &amp;#39;refs/heads/develop&amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    uses: ./.github/workflows/release-notes.yaml
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    secrets: inherit
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  post-release-notes-production:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    needs: [deploy-production]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    if: github.ref == &amp;#39;refs/heads/main&amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    uses: ./.github/workflows/release-notes.yaml
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    secrets: inherit&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each one runs independently after its respective deploy job, on the appropriate branch.&lt;/p&gt;
&lt;p&gt;You could trigger a workflow via the GitHub API with a &lt;code&gt;curl&lt;/code&gt; call to the &lt;code&gt;workflow_dispatch&lt;/code&gt; endpoint — and this is sometimes the right move when the target workflow lives in a different repository. But within the same repo, it&apos;s an awkward fit. You&apos;re introducing an async HTTP call where a direct dependency would do, you lose visibility into the triggered run from the calling workflow&apos;s summary page, and you have to handle auth tokens explicitly.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;workflow_call&lt;/code&gt;, the called workflow appears as a regular job in the same run. It shows up in the same Actions UI view, its logs are right there, and failures propagate naturally without any custom error handling.&lt;/p&gt;
&lt;p&gt;The main practical benefit of this approach isn&apos;t just reusability — it&apos;s that it lets you keep each workflow file focused on one thing. A deployment workflow shouldn&apos;t have to know about Jira, or Slack, or whatever notification system you&apos;re using. That logic belongs in its own file, tested and maintained separately, triggered wherever it&apos;s needed.&lt;/p&gt;
&lt;p&gt;Reusable workflows are one of the more underused features of GitHub Actions. The documentation covers them, but the examples tend to stop at &quot;here&apos;s the syntax.&quot; In practice, the pattern of splitting post-deployment tasks into callable workflows — and composing them from the main CI/CD pipeline — scales well as pipelines grow and keeps things readable as teams expand.&lt;/p&gt;
&lt;p&gt;If your deployment workflow is getting long, it&apos;s worth looking at what could be pulled out into a reusable workflow. Chances are, more of it is portable than you&apos;d expect.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/devops&quot;&gt;#devops&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/github&quot;&gt;#github&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-25T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/chaining-github-actions-workflows-the-right-way</id>
    <title>🐥 Chaining GitHub Actions Workflows the right way</title>
    <updated>2026-08-25T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/faster-leaner-mysql-backups-and-restores-with-mysqldump" rel="alternate"/>
    <content type="html">&lt;p&gt;&lt;code&gt;mysqldump&lt;/code&gt; is a perfectly fine tool for many production workloads — until your database grows large enough that backups take too long and restores take even longer. Before you reach for a more exotic solution, there are several flags and session-level variables that can make a meaningful difference with zero infrastructure changes.&lt;/p&gt;
&lt;p&gt;This article focuses on plain MySQL, but shows where to wire up these settings when you use &lt;a href=&quot;https://github.com/spatie/laravel-backup&quot;&gt;spatie/laravel-backup&lt;/a&gt; and &lt;a href=&quot;https://github.com/stefanzweifel/laravel-backup-restore&quot;&gt;stefanzweifel/laravel-backup-restore&lt;/a&gt; in a Laravel project.&lt;/p&gt;
&lt;h1&gt;Where to configure this in Laravel&lt;/h1&gt;
&lt;p&gt;Both packages read dump and restore options from &lt;code&gt;config/database.php&lt;/code&gt;, inside the &lt;code&gt;dump&lt;/code&gt; key of your connection:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;// config/database.php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;&amp;#39;mysql&amp;#39; =&gt; [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    // ... standard connection settings ...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    &amp;#39;dump&amp;#39; =&gt; [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        &amp;#39;excludeTables&amp;#39; =&gt; [...],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;        &amp;#39;useSingleTransaction&amp;#39; =&gt; true,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;        &amp;#39;add_extra_option&amp;#39; =&gt; &amp;#39;...&amp;#39;, // passed to mysqldump (backup)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;        &amp;#39;options&amp;#39; =&gt; &amp;#39;...&amp;#39;,          // passed to mysql client (restore)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    ],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;],&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;add_extra_option&lt;/code&gt; is forwarded to &lt;code&gt;mysqldump&lt;/code&gt; by spatie/laravel-backup.&lt;br /&gt;
&lt;code&gt;options&lt;/code&gt; is forwarded to the &lt;code&gt;mysql&lt;/code&gt; client by laravel-backup-restore.&lt;/p&gt;
&lt;p&gt;Everything below maps directly to one of those two keys.&lt;/p&gt;
&lt;h1&gt;Dump-time optimisations&lt;/h1&gt;
&lt;h2&gt;Exclude noisy, non-essential tables&lt;/h2&gt;
&lt;p&gt;Some tables accumulate millions of rows that are useful for debugging but worthless in a restore. Excluding them shrinks the dump file and cuts restore time dramatically.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;&amp;#39;excludeTables&amp;#39; =&gt; [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    &amp;#39;telescope_entries&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    &amp;#39;telescope_entries_tags&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    &amp;#39;telescope_monitoring&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;],&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Laravel Telescope is the classic example: it can easily outweigh the rest of your schema combined.&lt;/p&gt;
&lt;h2&gt;Use a single transaction&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--single-transaction&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In spatie/laravel-backup this is:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;&amp;#39;useSingleTransaction&amp;#39; =&gt; true,&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This wraps the dump in a &lt;code&gt;START TRANSACTION&lt;/code&gt; so InnoDB tables are read from a consistent snapshot without locking them. Essential for any live database.&lt;/p&gt;
&lt;h2&gt;Increase &lt;code&gt;--net-buffer-length&lt;/code&gt;&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--net-buffer-length=16777216   # 16 MB (default is 1 MB)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mysqldump&lt;/code&gt; groups rows into multi-row &lt;code&gt;INSERT&lt;/code&gt; statements. The default maximum per statement is 1 MB. Raising it to 16 MB reduces the total number of statements in the file, which directly cuts parse and execution time during a restore.&lt;/p&gt;
&lt;p&gt;Pair it with &lt;code&gt;--max_allowed_packet&lt;/code&gt; on the server side:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--max_allowed_packet=512M&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without this, large packets are silently rejected.&lt;/p&gt;
&lt;h2&gt;Skip unnecessary LOCK/UNLOCK statements&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--skip-add-locks&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mysqldump&lt;/code&gt; normally wraps each table dump in &lt;code&gt;LOCK TABLES … WRITE&lt;/code&gt; / &lt;code&gt;UNLOCK TABLES&lt;/code&gt;. This is redundant when you are already using &lt;code&gt;--single-transaction&lt;/code&gt; and when you disable table locking at restore time (see below). Removing these statements makes the dump file smaller and the restore faster.&lt;/p&gt;
&lt;h2&gt;Suppress MySQL 8 column statistics&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--column-statistics=0&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MySQL 8&apos;s &lt;code&gt;mysqldump&lt;/code&gt; emits &lt;code&gt;ANALYZE TABLE&lt;/code&gt; statements to update column statistics after each table is imported. On large tables these can block progress noticeably. If you run your own statistics collection after a restore, suppress them.&lt;/p&gt;
&lt;h2&gt;Compress the dump on the wire&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--compression-algorithms=zlib&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compresses data in transit between &lt;code&gt;mysqldump&lt;/code&gt; and the MySQL server. Useful when the client and server are on separate hosts, reducing network I/O. For socket connections the benefit is minimal, but there is no downside.&lt;/p&gt;
&lt;h2&gt;Skip binary log tracking (GTID)&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--set-gtid-purged=OFF&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you use GTID-based replication, &lt;code&gt;mysqldump&lt;/code&gt; normally writes &lt;code&gt;SET @@GLOBAL.gtid_purged&lt;/code&gt; into the dump. During a restore this can conflict with an existing &lt;code&gt;gtid_executed&lt;/code&gt; set. Passing &lt;code&gt;OFF&lt;/code&gt; omits that statement and keeps restores clean on secondary or development databases.&lt;/p&gt;
&lt;h2&gt;Avoid table-level locks&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--lock-tables=false&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When using &lt;code&gt;--single-transaction&lt;/code&gt;, table-level locking is already unnecessary. Explicitly disabling it avoids a redundant flush.&lt;/p&gt;
&lt;h2&gt;Use &lt;code&gt;--quick&lt;/code&gt; for large tables&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;--quick&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default &lt;code&gt;mysqldump&lt;/code&gt; buffers an entire table in memory before writing. &lt;code&gt;--quick&lt;/code&gt; streams one row at a time, keeping memory usage flat regardless of table size. Always enable it for any production database.&lt;/p&gt;
&lt;h1&gt;Restore-time optimisations&lt;/h1&gt;
&lt;p&gt;These are passed to the &lt;code&gt;mysql&lt;/code&gt; client via an &lt;code&gt;--init-command&lt;/code&gt;, which MySQL executes before the dump is processed:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;SET sql_log_bin=0;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;SET foreign_key_checks=0;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;SET unique_checks=0;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;SET autocommit=0;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In &lt;code&gt;config/database.php&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;&amp;#39;options&amp;#39; =&gt; &amp;#39;--init-command=&quot;SET sql_log_bin=0; SET foreign_key_checks=0; SET unique_checks=0; SET autocommit=0;&quot;&amp;#39;,&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;code&gt;SET sql_log_bin=0&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Disables binary logging for the session. There is no point logging every INSERT from a dump file into the binlog — it inflates the binlog and slows the restore. Safe when you control the import and are not relying on the binlog to propagate changes to replicas. This also reduces the amount of disk space needed for the restore.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;SET foreign_key_checks=0&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;MySQL enforces referential integrity row-by-row during inserts. In a full dump, the parent and child rows are all present — you just may not have inserted the parent yet when a child arrives. Disabling this check lets MySQL trust the dump and skip the per-row lookups, which is one of the most impactful restore optimisations available.&lt;/p&gt;
&lt;p&gt;Re-enable it (or restart the session) after the restore to verify the restored data is consistent.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;SET unique_checks=0&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Similar rationale: MySQL normally verifies unique constraints on every insert. A correctly generated dump has no duplicates, so this check is redundant overhead. Disabling it reduces index update cost during the restore.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;SET autocommit=0&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Turns off the implicit &lt;code&gt;COMMIT&lt;/code&gt; after every statement. Combined with the large &lt;code&gt;INSERT&lt;/code&gt; batches produced by &lt;code&gt;--net-buffer-length&lt;/code&gt;, this means the storage engine can batch many rows into a single transaction, dramatically reducing fsync pressure.&lt;/p&gt;
&lt;h1&gt;Putting it together&lt;/h1&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;// config/database.php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;&amp;#39;dump&amp;#39; =&gt; [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    &amp;#39;excludeTables&amp;#39; =&gt; [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;        &amp;#39;telescope_entries&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        &amp;#39;telescope_entries_tags&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;        &amp;#39;telescope_monitoring&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    ],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    &amp;#39;useSingleTransaction&amp;#39; =&gt; true,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    &amp;#39;add_extra_option&amp;#39; =&gt; &amp;#39;--set-gtid-purged=OFF --lock-tables=false --quick&amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;        . &amp;#39; --max_allowed_packet=512M --net-buffer-length=16777216&amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;        . &amp;#39; --compression-algorithms=zlib&amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;        . &amp;#39; --skip-add-locks --column-statistics=0&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;    &amp;#39;options&amp;#39; =&gt; &amp;#39;--init-command=&quot;SET sql_log_bin=0; SET foreign_key_checks=0; SET unique_checks=0; SET autocommit=0;&quot;&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;],&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These settings are safe for the vast majority of production MySQL 8 setups. The restore-side flags assume you trust the dump and will verify data integrity via application-level health checks afterwards — which is standard practice regardless of how you import.&lt;/p&gt;
&lt;p&gt;The backup side produces a smaller file, faster. The restore side skips redundant constraint checking and batches commits. Together they can reduce both phases by a significant margin without changing anything about your infrastructure or your backup tooling.&lt;/p&gt;
&lt;h1&gt;More info&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://spatie.be/docs/laravel-backup/v10/introduction&quot;&gt;Spatie Backup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://stefanzweifel.dev/posts/2023/06/15/introducing-laravel-backup-restore/&quot;&gt;Introducing laravel-backup-restore
&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/9.7/en/mysqldump.html&quot;&gt;mysqldump — A Database Backup Program&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/9.7/en/mysql.html&quot;&gt;mysql — The MySQL Command-Line Client&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/mysql&quot;&gt;#mysql&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/devops&quot;&gt;#devops&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-22T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/faster-leaner-mysql-backups-and-restores-with-mysqldump</id>
    <title>🐥 Faster, leaner MySQL backups and restores with mysqldump</title>
    <updated>2026-08-22T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/speeding-up-our-php-test-suite-with-a-github-actions-matrix-strategy" rel="alternate"/>
    <content type="html">&lt;p&gt;Our test suite had become a bottleneck. Every push to &lt;code&gt;develop&lt;/code&gt; or &lt;code&gt;main&lt;/code&gt; triggered three separate CI jobs — unit tests, feature tests, and integration tests — each running sequentially after the previous one finished and each downloading the same apt packages from scratch. The result: slow feedback loops and a lot of redundant work.&lt;/p&gt;
&lt;p&gt;This post walks through the changes we made to fix that.&lt;/p&gt;
&lt;h1&gt;The Problem: Three Jobs, Lots of Duplication&lt;/h1&gt;
&lt;p&gt;Before this change, the CI pipeline defined three independent jobs:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;phpunit-unit:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  name: &quot;PHP Unit Tests&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  steps:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    - name: Install packages
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;      run: |
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;        sudo apt update
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        sudo apt install -y package-a package-b package-c
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    - name: Execute tests
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;      run: php artisan test --parallel tests/Unit
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;phpunit-feature:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;  name: &quot;PHP Feature Tests&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;  steps:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    # same apt install block...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;    - name: Execute tests
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;      run: php artisan test --parallel tests/Feature
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;phpunit-integration:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;  name: &quot;PHP Integration Tests&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;  steps:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;    # same apt install block again...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;    - name: Execute tests
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;      run: php artisan test --parallel tests/Integration&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each job was:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Checking out the repo&lt;/strong&gt; independently&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Setting up PHP&lt;/strong&gt; independently&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Installing Composer dependencies&lt;/strong&gt; independently (even with caching, cache restores take time)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Running &lt;code&gt;apt-get install&lt;/code&gt;&lt;/strong&gt; with the same packages — every single time, with no caching&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Step 1: Collapsing Three Jobs into a Matrix&lt;/h1&gt;
&lt;p&gt;The first change replaced the three separate job definitions with a single &lt;code&gt;phpunit&lt;/code&gt; job that uses GitHub Actions&apos; &lt;code&gt;matrix&lt;/code&gt; strategy:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;phpunit:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  name: &quot;PHP Tests (${{ matrix.suite }})&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  runs-on: ubuntu-latest
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  strategy:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    fail-fast: false
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    matrix:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;      include:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;        - suite: unit-a
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;        - suite: unit-b
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;        - suite: feature-a
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;        - suite: feature-b
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;        - suite: Integration&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All suites run in parallel, with &lt;code&gt;fail-fast: false&lt;/code&gt; so a failure in one suite doesn&apos;t cancel the others mid-run.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Execute tests&lt;/code&gt; step became a single parameterised command:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;- name: Execute tests
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  run: php artisan test --parallel --testsuite=${{ matrix.suite }} --coverage-clover test-coverage/coverage-${{ matrix.suite }}.xml&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Why More Suites Instead of Fewer?&lt;/h2&gt;
&lt;p&gt;The original split was coarse: all unit tests ran together, and all feature tests ran together. One large bucket would hold everything up even when most of the tests in it finished quickly.&lt;/p&gt;
&lt;p&gt;By naming suites explicitly in &lt;code&gt;phpunit.xml&lt;/code&gt;, we got finer-grained parallelism:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-xml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;testsuite name=&quot;unit-a&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  &lt;directory suffix=&quot;.php&quot;&gt;./tests/Unit/GroupA&lt;/directory&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;&lt;/testsuite&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;&lt;testsuite name=&quot;unit-b&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  &lt;directory suffix=&quot;.php&quot;&gt;./tests/Unit/GroupB&lt;/directory&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  &lt;directory suffix=&quot;.php&quot;&gt;./tests/Unit/GroupC&lt;/directory&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;&lt;/testsuite&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;&lt;testsuite name=&quot;feature-a&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;  &lt;directory suffix=&quot;.php&quot;&gt;./tests/Feature/GroupA&lt;/directory&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;&lt;/testsuite&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;&lt;testsuite name=&quot;feature-b&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;  &lt;directory suffix=&quot;.php&quot;&gt;./tests/Feature/GroupB&lt;/directory&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;  &lt;directory suffix=&quot;.php&quot;&gt;./tests/Feature/GroupC&lt;/directory&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;&lt;/testsuite&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps each matrix leg roughly balanced in terms of test count and makes the overall wall-clock time shorter than when one slow bucket holds everything up.&lt;/p&gt;
&lt;h1&gt;Step 2: Caching apt Packages&lt;/h1&gt;
&lt;p&gt;The most wasteful part of the old setup was reinstalling the same system packages on every runner, for every job, from scratch. &lt;code&gt;apt-get install&lt;/code&gt; isn&apos;t slow in absolute terms, but multiplied across five parallel jobs and every CI run, it adds up.&lt;/p&gt;
&lt;p&gt;We replaced the raw &lt;code&gt;apt install&lt;/code&gt; block with &lt;a href=&quot;https://github.com/awalsh128/cache-apt-pkgs-action&quot;&gt;&lt;code&gt;awalsh128/cache-apt-pkgs-action&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;- name: Install packages
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  uses: awalsh128/cache-apt-pkgs-action@latest
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  with:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    packages: package-a package-b package-c
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    version: ${{ hashFiles(&amp;#39;.apt-packages&amp;#39;) }}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;version&lt;/code&gt; key is tied to a &lt;code&gt;.apt-packages&lt;/code&gt; lockfile committed to the repo. The cache is invalidated only when that file changes — not on every push — so the common case is a fast cache hit rather than a full install.&lt;/p&gt;
&lt;p&gt;Any post-install configuration that previously lived inside the install block becomes its own named step. The action calls it only on a cache miss, so it doesn&apos;t run unnecessarily on cache hits:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;- name: Post-install configuration
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  run: # your configuration command here&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;The Downstream Effect: Coverage Merging&lt;/h1&gt;
&lt;p&gt;Any job that previously depended on the three separate job names now depends on the single &lt;code&gt;phpunit&lt;/code&gt; job:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;some-downstream-job:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  needs: [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    &quot;phpunit&quot;,   # was: phpunit-unit, phpunit-feature, phpunit-integration
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    ...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  ]&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the coverage merge step includes one file per suite:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;./clover-merge -o coverage.xml \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  unit-a/coverage-unit-a.xml \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  unit-b/coverage-unit-b.xml \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  feature-a/coverage-feature-a.xml \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  feature-b/coverage-feature-b.xml \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  integration/coverage-integration.xml&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;What This Changes&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3 jobs, one per test type&lt;/td&gt;
&lt;td&gt;5 jobs, all parallel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apt install&lt;/code&gt; on every run, every job&lt;/td&gt;
&lt;td&gt;Cached apt packages, invalidated only on lockfile change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coarse path-based test selection&lt;/td&gt;
&lt;td&gt;Named testsuites in &lt;code&gt;phpunit.xml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~3× duplicated job boilerplate&lt;/td&gt;
&lt;td&gt;Single parameterised job definition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The wall-clock time for the PHP test stage drops because the legs run concurrently and the apt install step is usually a cache hit. Maintenance is easier too — adding a new suite is a two-line change: one entry in &lt;code&gt;phpunit.xml&lt;/code&gt; and one entry in the matrix &lt;code&gt;include&lt;/code&gt; list.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/php&quot;&gt;#php&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/devops&quot;&gt;#devops&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/github&quot;&gt;#github&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/testing&quot;&gt;#testing&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-20T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/speeding-up-our-php-test-suite-with-a-github-actions-matrix-strategy</id>
    <title>🐥 Speeding up our PHP test suite with a GitHub Actions matrix strategy</title>
    <updated>2026-08-20T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/advanced-debugging-in-elixir-with-io-inspect" rel="alternate"/>
    <content type="html">&lt;p&gt;When writing Elixir, most developers quickly get familiar with &lt;a href=&quot;https://hexdocs.pm/elixir/IO.html#inspect/2&quot;&gt;&lt;code&gt;IO.inspect/2&lt;/code&gt;&lt;/a&gt; as a quick way to see what&apos;s happening inside their code. But what many overlook is that &lt;code&gt;IO.inspect&lt;/code&gt; is far more powerful than just &quot;print this variable to the console.&quot;&lt;/p&gt;
&lt;p&gt;In fact, with the right options and placement, &lt;code&gt;IO.inspect&lt;/code&gt; can become a precise, highly targeted debugging tool, one that doesn&apos;t interrupt your program flow and works seamlessly with Elixir&apos;s functional pipelines.&lt;/p&gt;
&lt;p&gt;This post will walk through both the fundamentals and advanced patterns for using &lt;code&gt;IO.inspect&lt;/code&gt; effectively. By the end, you&apos;ll know how to control output formatting, label your prints for clarity, debug concurrent processes, and even integrate conditional or file-based inspection.&lt;/p&gt;
&lt;h1&gt;The Basics of &lt;code&gt;IO.inspect&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://hexdocs.pm/elixir/IO.html#inspect/2&quot;&gt;&lt;code&gt;IO.inspect&lt;/code&gt;&lt;/a&gt; is defined like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;IO.inspect(item, opts \\ [])&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;item&lt;/strong&gt;: any Elixir value you want to inspect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;opts&lt;/strong&gt;: keyword list of options for controlling how the term is printed as defined in &lt;a href=&quot;https://hexdocs.pm/elixir/Inspect.Opts.html&quot;&gt;&lt;code&gt;Inspect.Opts&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By default, it writes the inspected value to the standard output (&lt;code&gt;:stdio&lt;/code&gt;) and then returns the term unchanged.&lt;/p&gt;
&lt;p&gt;That last part is key. Because it returns the term, you can drop it anywhere in a function or pipeline without breaking the flow.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;defmodule Demo do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  def run do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    IO.inspect(%{name: &quot;Alice&quot;, age: 30})
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prints:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;%{age: 30, name: &quot;Alice&quot;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Caveat: When &lt;code&gt;IO.inspect&lt;/code&gt; Is the Last Call in a Function&lt;/h1&gt;
&lt;p&gt;Because &lt;code&gt;IO.inspect&lt;/code&gt; returns its argument, it also means that if it&apos;s the &lt;strong&gt;last expression in your function&lt;/strong&gt;, that inspected value becomes the function&apos;s return value.&lt;/p&gt;
&lt;p&gt;That&apos;s fine if you &lt;em&gt;intend&lt;/em&gt; to return it, but it can lead to subtle bugs if you were only printing it for debugging and expected a different return.&lt;/p&gt;
&lt;p&gt;Take this example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;def fetch_user(id) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  Repo.get(User, id)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  |&gt; IO.inspect(label: &quot;Fetched user&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, the function returns the user struct as normal, which is fine.&lt;/p&gt;
&lt;p&gt;Now, consider:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;def log_and_return do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  IO.inspect(&quot;Done!&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This returns &lt;code&gt;&quot;Done!&quot;&lt;/code&gt; instead of, say, &lt;code&gt;:ok&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you want to print something &lt;strong&gt;but return a different value&lt;/strong&gt;, make the return explicit:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;def log_and_return do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  IO.inspect(&quot;Done!&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  :ok
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or if you&apos;re in a pipeline:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;value
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;|&gt; IO.inspect(label: &quot;Debug&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;|&gt; do_something_else()&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; if &lt;code&gt;IO.inspect&lt;/code&gt; is the last expression in a function, be explicit about what you want to return.&lt;/p&gt;
&lt;h1&gt;Strategic Placement With the Pipe Operator&lt;/h1&gt;
&lt;p&gt;Because &lt;code&gt;IO.inspect&lt;/code&gt; returns its argument, it fits perfectly in the middle of a pipeline:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;users
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;|&gt; IO.inspect(label: &quot;Before filtering&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;|&gt; Enum.filter(&amp; &amp;1.active)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;|&gt; IO.inspect(label: &quot;After filtering&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;|&gt; Enum.map(&amp; &amp;1.name)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This lets you peek into the data flow at exactly the point you want, without rewriting your code into intermediate variables.&lt;/p&gt;
&lt;p&gt;A neat trick is to place multiple &lt;code&gt;IO.inspect&lt;/code&gt; calls at different stages, each with a distinct label, so you can see how the data changes step by step.&lt;/p&gt;
&lt;h1&gt;Using Labels for Clarity&lt;/h1&gt;
&lt;p&gt;Without labels, multiple inspection outputs can be hard to tell apart. The &lt;code&gt;label&lt;/code&gt; option solves this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;IO.inspect(data, label: &quot;After filtering&quot;)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prints:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;After filtering: [%{name: &quot;Alice&quot;}, %{name: &quot;Bob&quot;}]&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When debugging a pipeline with several inspect points, labels make the output self-describing. This is especially useful when you&apos;re debugging multiple similar data structures in the same run.&lt;/p&gt;
&lt;h1&gt;Pretty Printing and Formatting Output&lt;/h1&gt;
&lt;p&gt;Sometimes, especially with large maps or deeply nested lists, the default single-line output is hard to read. That&apos;s where formatting options defined in &lt;a href=&quot;https://hexdocs.pm/elixir/Inspect.Opts.html&quot;&gt;&lt;code&gt;Inspect.Opts&lt;/code&gt;&lt;/a&gt; come in.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:pretty&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;if set to &lt;code&gt;true&lt;/code&gt; enables pretty printing.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:limit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;limits the number of items that are inspected for tuples, bitstrings, maps, lists and any other collection of items, with the exception of printable strings and printable charlists which use the &lt;code&gt;:printable_limit&lt;/code&gt; option. If you don&apos;t want to limit the number of items to a particular number, use &lt;code&gt;:infinity&lt;/code&gt;. It accepts a positive integer or &lt;code&gt;:infinity&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;50&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:printable_limit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;limits the number of characters that are inspected on printable strings and printable charlists. You can use &lt;a href=&quot;https://hexdocs.pm/elixir/String.html#printable?/1&quot;&gt;&lt;code&gt;String.printable?/1&lt;/code&gt;&lt;/a&gt; and &lt;a href=&quot;https://hexdocs.pm/elixir/List.html#ascii_printable?/1&quot;&gt;&lt;code&gt;List.ascii_printable?/1&lt;/code&gt;&lt;/a&gt; to check if a given string or charlist is printable. If you don&apos;t want to limit the number of characters to a particular number, use &lt;code&gt;:infinity&lt;/code&gt;. It accepts a positive integer or &lt;code&gt;:infinity&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;4096&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:width&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;number of characters per line used when pretty is &lt;code&gt;true&lt;/code&gt; or when printing to IO devices. Set to &lt;code&gt;0&lt;/code&gt; to force each item to be printed on its own line. If you don&apos;t want to limit the number of items to a particular number, use &lt;code&gt;:infinity&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;80&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:charlists&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;when &lt;code&gt;:as_charlists&lt;/code&gt; all lists will be printed as charlists, non-printable elements will be escaped. When &lt;code&gt;:as_lists&lt;/code&gt; all lists will be printed as lists. When the default &lt;code&gt;:infer&lt;/code&gt;, the list will be printed as a charlist if it is printable, otherwise as list. See &lt;a href=&quot;https://hexdocs.pm/elixir/List.html#ascii_printable?/1&quot;&gt;&lt;code&gt;List.ascii_printable?/1&lt;/code&gt;&lt;/a&gt; to learn when a charlist is printable.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:infer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;data = for i &lt;- 1..100 do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  %{id: i, value: String.duplicate(&quot;x&quot;, i)}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;IO.inspect(data, pretty: true, limit: 5)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prints:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;[
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  %{id: 1, value: &quot;x&quot;},
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  %{id: 2, value: &quot;xx&quot;},
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  %{id: 3, value: &quot;xxx&quot;},
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  %{id: 4, ...},
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  %{...},
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  ...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;]&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, we can see the first five entries, and the rest are summarized with &lt;code&gt;...&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;Coloring and Styling Output&lt;/h1&gt;
&lt;p&gt;Elixir&apos;s inspect options support syntax coloring, very handy when your terminal is full of logs.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;IO.inspect(data,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  syntax_colors: [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    atom: :blue,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    string: :green,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    number: :red
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  ],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  pretty: true
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes atoms blue, strings green, and numbers red in your console output.&lt;/p&gt;
&lt;p&gt;Colors can be any &lt;a href=&quot;https://hexdocs.pm/elixir/IO.ANSI.html#t:ansidata/0&quot;&gt;&lt;code&gt;IO.ANSI.ansidata/0&lt;/code&gt;&lt;/a&gt; as accepted by &lt;a href=&quot;https://hexdocs.pm/elixir/IO.ANSI.html#format/1&quot;&gt;&lt;code&gt;IO.ANSI.format/1&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you want to use the default colors (like what is used in &lt;code&gt;IEx&lt;/code&gt;), you can use &lt;a href=&quot;https://hexdocs.pm/elixir/IO.ANSI.html#syntax_colors/0&quot;&gt;&lt;code&gt;IO.ANSI.syntax_colors/0&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;IO.inspect(data,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  syntax_colors: IO.ANSI.syntax_colors(),
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  pretty: true
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The colors are only visible if your terminal supports ANSI colors.&lt;/p&gt;
&lt;h1&gt;Conditional Inspection&lt;/h1&gt;
&lt;p&gt;Sometimes you want to inspect only if a certain condition is true, for example, when a debug flag is enabled or when a value crosses a threshold.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example with a debug flag:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;def debug(term) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  if Application.get_env(:my_app, :debug) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    IO.inspect(term, label: &quot;Debug&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  term
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can use this function like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;users
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;|&gt; Enum.filter(&amp; &amp;1.active)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;|&gt; debug()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;|&gt; Enum.map(&amp; &amp;1.name)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can toggle the output by setting &lt;code&gt;config :my_app, :debug, true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;Capturing Inspect Output Instead of Printing&lt;/h1&gt;
&lt;p&gt;If you want to get the inspected form of a value without printing it, use &lt;a href=&quot;https://hexdocs.pm/elixir/Inspect.html#inspect/2&quot;&gt;&lt;code&gt;inspect/2&lt;/code&gt;&lt;/a&gt; (without the &lt;code&gt;IO.&lt;/code&gt; prefix):&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;string_representation = inspect(data, pretty: true)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is useful if you want to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Write the debug output to a file&lt;/li&gt;
&lt;li&gt;Send it to a logging service&lt;/li&gt;
&lt;li&gt;Include it in an exception message&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;File.write!(&quot;debug.log&quot;, inspect(data, pretty: true))&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also redirect &lt;code&gt;IO.inspect&lt;/code&gt; output to another device:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;IO.inspect(data, label: &quot;Debug&quot;, device: :stderr)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Debugging Concurrency and Async Code&lt;/h1&gt;
&lt;p&gt;When you use &lt;code&gt;IO.inspect&lt;/code&gt; in asynchronous code, the output may arrive out of order, making it hard to follow. Adding identifiers or timestamps can help.&lt;/p&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;tasks =
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  for id &lt;- 1..3 do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    Task.async(fn -&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;      IO.inspect(self(), label: &quot;PID #{id}&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;      :timer.sleep(100)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;      id * id
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    end)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;Enum.map(tasks, &amp;Task.await/1)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prints:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;PID 3: #PID&lt;0.112.0&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;PID 2: #PID&lt;0.111.0&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;PID 1: #PID&lt;0.110.0&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Including the PID or a unique request ID in your label helps you trace which output belongs to which process.&lt;/p&gt;
&lt;h1&gt;Advanced Trick: Inspecting and Pattern Matching in One Go&lt;/h1&gt;
&lt;p&gt;You can combine pattern matching with inspection to see exactly what&apos;s being matched:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;%{id: id} = IO.inspect(user, label: &quot;User before insert&quot;)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This inspects the &lt;code&gt;user&lt;/code&gt; variable before pattern matching extracts the &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can also place &lt;code&gt;IO.inspect&lt;/code&gt; inside a guard to conditionally print:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;case user do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  %{role: :admin} = u -&gt; IO.inspect(u, label: &quot;Admin user&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  _ -&gt; :ok
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this last example, &quot;Admin user&quot; will only be printed if the user has the role &lt;code&gt;:admin&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;Using &lt;code&gt;dbg/2&lt;/code&gt; for Richer Inspection&lt;/h1&gt;
&lt;p&gt;Since Elixir &lt;strong&gt;1.14&lt;/strong&gt;, we have &lt;a href=&quot;https://hexdocs.pm/elixir/Kernel.html#dbg/2&quot;&gt;&lt;code&gt;dbg/2&lt;/code&gt;&lt;/a&gt;, a built-in debugging helper that works like &lt;code&gt;IO.inspect&lt;/code&gt; but &lt;strong&gt;also shows the code expression&lt;/strong&gt; that produced the value.&lt;/p&gt;
&lt;p&gt;You can use it like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;users |&gt; Enum.filter(&amp; &amp;1.active) |&gt; dbg()&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which prints:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;users #=&gt; [%{active: false, name: &quot;Alice&quot;}, %{active: true, name: &quot;Bob&quot;}]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;|&gt; Enum.filter(&amp; &amp;1.active) #=&gt; [%{active: true, name: &quot;Bob&quot;}]&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes it much easier to understand &lt;em&gt;where&lt;/em&gt; in your code the inspected value is coming from, especially when you&apos;re inspecting multiple similar-looking values.&lt;/p&gt;
&lt;p&gt;You can also pass options similar to &lt;code&gt;IO.inspect&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;dbg(users, label: &quot;Active users&quot;, pretty: true)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here are the key differences with &lt;code&gt;IO.inspect&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Shows the code expression&lt;/strong&gt; automatically.&lt;/li&gt;
&lt;li&gt;Output format is slightly more verbose.&lt;/li&gt;
&lt;li&gt;Same return behavior, returns the inspected value, so you can keep it in a pipeline.&lt;/li&gt;
&lt;li&gt;Best for &lt;strong&gt;interactive debugging&lt;/strong&gt; during development.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;When &lt;code&gt;IO.inspect&lt;/code&gt; Is Not Enough&lt;/h1&gt;
&lt;p&gt;While &lt;code&gt;IO.inspect&lt;/code&gt; is a fantastic quick-and-dirty tool, there are times when you need more powerful debugging:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://hexdocs.pm/iex/IEx.html#pry/0&quot;&gt;&lt;code&gt;IEx.pry&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;: drops you into an interactive REPL inside the running process.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.erlang.org/doc/apps/observer/observer.html#start/0&quot;&gt;&lt;code&gt;:observer.start/0&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;: Erlang&apos;s GUI for monitoring processes, memory, and more.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trick is to know when to reach for &lt;code&gt;IO.inspect&lt;/code&gt; and when to switch to one of these other tools.&lt;/p&gt;
&lt;h1&gt;Setting Default Options for &lt;code&gt;IO.inspect&lt;/code&gt; in IEx&lt;/h1&gt;
&lt;p&gt;When using &lt;code&gt;IEx&lt;/code&gt;, you can configure the default options for &lt;code&gt;IO.inspect&lt;/code&gt;. You can do this using the &lt;a href=&quot;https://hexdocs.pm/iex/IEx.html#configure/1&quot;&gt;&lt;code&gt;IEx.configure/1&lt;/code&gt;&lt;/a&gt; function:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;IEx.configure(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  colors: [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    syntax_colors: [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;      number: :light_yellow,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;      atom: :light_cyan,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;      string: :light_black,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;      boolean: :red,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;      nil: [:magenta, :bright]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    ],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    ls_directory: :cyan,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    ls_device: :yellow,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;    doc_code: :green,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    doc_inline_code: :magenta,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    doc_headings: [:cyan, :underline],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;    doc_title: [:cyan, :bright, :underline]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;  ]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can easily put this in your &lt;a href=&quot;https://hexdocs.pm/iex/IEx.html#module-configuring-the-shell&quot;&gt;&lt;code&gt;.iex.exs&lt;/code&gt;&lt;/a&gt; file so that it&apos;s applied automatically every time you open the shell.&lt;/p&gt;
&lt;h1&gt;Creating a Reusable Inspect Helper&lt;/h1&gt;
&lt;p&gt;Here&apos;s a neat way to wrap &lt;code&gt;IO.inspect/2&lt;/code&gt; into your own helper module with preferred defaults. This way, you can keep consistent inspection output without repeating options everywhere:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;defmodule MyApp.Debug do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  @moduledoc &quot;&quot;&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  Convenience wrapper around `IO.inspect/2` with sensible defaults.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  &quot;&quot;&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  @default_opts [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    label: &quot;DEBUG&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    pretty: true,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    limit: :infinity,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    width: 120,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    syntax_colors: IO.ANSI.syntax_colors()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;  ]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;  @doc &quot;&quot;&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;  Inspects a value with default debug options.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;  This is pipe-friendly: it returns the given value unchanged
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;  after inspecting it.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;  ## Examples
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;      iex&gt; [1, 2, 3]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;      ...&gt; |&gt; Enum.map(&amp;(&amp;1 * 2))
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;      ...&gt; |&gt; MyApp.Debug.inspect()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;      [2, 4, 6]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;26&quot;&gt;  &quot;&quot;&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;27&quot;&gt;  def inspect(term, opts \\ []) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;28&quot;&gt;    term
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;29&quot;&gt;    |&gt; IO.inspect(Keyword.merge(@default_opts, opts))
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;30&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;31&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can use it like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;result =
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  users
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  |&gt; Enum.filter(&amp;(&amp;1.active?))
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  |&gt; MyApp.Debug.inspect(label: &quot;Active users&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  |&gt; Enum.map(&amp; &amp;1.name)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  |&gt; MyApp.Debug.inspect(label: &quot;User names&quot;)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You get pretty printing (&lt;code&gt;pretty: true&lt;/code&gt;) by default.&lt;/li&gt;
&lt;li&gt;Lists and maps are not truncated (&lt;code&gt;limit: :infinity&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A label is always shown (&lt;code&gt;DEBUG&lt;/code&gt; unless overridden).&lt;/li&gt;
&lt;li&gt;You can still override any option per call.&lt;/li&gt;
&lt;li&gt;It works fine when it&apos;s in a pipeline.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Handy Visual Studio Code snippets&lt;/h1&gt;
&lt;p&gt;To make using &lt;code&gt;IO.inspect&lt;/code&gt; faster and more consistent, you can configure editor snippets in Visual Studio Code so you don&apos;t have to type repetitive boilerplate each time.&lt;/p&gt;
&lt;p&gt;In Visual Studio Code, open:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;Code -&gt; Settings… -&gt; Configure Snippets -&gt; Elixir&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and add the following snippet definitions:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-json&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  &quot;inpsect&quot;: {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    &quot;prefix&quot;: &quot;lin&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    &quot;body&quot;: &quot;IO.inspect($1, label: \&quot;$1\&quot;, pretty: true)&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    &quot;description&quot;: &quot;IO.inspect with label.&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  },
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  &quot;inpsectSelectedText&quot;: {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    &quot;prefix&quot;: &quot;sin&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    &quot;body&quot;: &quot;IO.inspect($TM_SELECTED_TEXT, label: \&quot;${TM_SELECTED_TEXT/(.*)/${1:/upcase}/}$0\&quot;, pretty: true)&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    &quot;description&quot;: &quot;IO.inspect with selected text as label.&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;  },
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;  &quot;pipeInspect&quot;: {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    &quot;prefix&quot;: &quot;pin&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    &quot;body&quot;: &quot;|&gt; IO.inspect(label: \&quot;$1\&quot;, pretty: true)&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;    &quot;description&quot;: &quot;IO.inspect with pipe.&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;  },
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;  &quot;pipeInspectWithFileReference&quot;: {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;    &quot;prefix&quot;: &quot;pinf&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;    &quot;body&quot;: &quot;|&gt; IO.inspect(label: \&quot;$TM_FILEPATH:$TM_LINE_NUMBER$0\&quot;, pretty: true)&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;    &quot;description&quot;: &quot;IO.inspect with pipe and file reference.&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;  },
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;  &quot;inspectFromClipboard&quot;: {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;    &quot;prefix&quot;: &quot;cin&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;    &quot;body&quot;: &quot;IO.inspect($CLIPBOARD, label: \&quot;${CLIPBOARD/(.*)/${1:/upcase}/}$0\&quot;, pretty: true)&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;    &quot;description&quot;: &quot;IO.inspect clipboard content.&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;26&quot;&gt;  }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;27&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With these in place, you&apos;ll have short prefixes to insert commonly used &lt;code&gt;IO.inspect/2&lt;/code&gt; patterns:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IO.inspect&lt;/code&gt; with label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IO.inspect&lt;/code&gt; using selected text as label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IO.inspect&lt;/code&gt; in a pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pinf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IO.inspect&lt;/code&gt; in a pipeline with file and line reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IO.inspect&lt;/code&gt; clipboard content&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This way, you can quickly drop in debug output with consistent formatting, colors, and labels, without breaking your flow.&lt;/p&gt;
&lt;p&gt;Suppose you are transforming a list of user maps and want to check intermediate results inside a pipeline:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;users
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;|&gt; Enum.filter(&amp;(&amp;1.active))
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;|&gt; IO.inspect(label: &quot;After filter&quot;, pretty: true)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;|&gt; Enum.map(&amp; &amp;1.email)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With the snippet defined above, you don&apos;t have to type all that. Just type &lt;code&gt;pin&lt;/code&gt;, hit &lt;strong&gt;Tab&lt;/strong&gt;, and you get:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;|&gt; IO.inspect(label: &quot;&quot;, pretty: true)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can immediately type your label (e.g. &lt;code&gt;&quot;After filter&quot;&lt;/code&gt;) and continue coding. This keeps your debugging consistent, colorful, and fast, without breaking your flow.&lt;/p&gt;
&lt;h1&gt;Best practices&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use labels liberally&lt;/strong&gt;, unlabeled output is harder to parse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Limit output&lt;/strong&gt; for large collections, use &lt;code&gt;limit&lt;/code&gt; and &lt;code&gt;pretty&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Avoid leaving it in production&lt;/strong&gt; unless intentional.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wrap it&lt;/strong&gt; in helper functions when you want conditional control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tag concurrent output&lt;/strong&gt; with PIDs, timestamps, or request IDs.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hexdocs.pm/credo/overview.html&quot;&gt;&lt;strong&gt;Credo&lt;/strong&gt;&lt;/a&gt; can be used to detect unintentional calls to &lt;code&gt;IO.inspect&lt;/code&gt; in a CI/CD pipeline.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;IO.inspect&lt;/code&gt; may look like a humble debugging tool, but in Elixir it&apos;s a powerful way to see what&apos;s going on without breaking your code&apos;s flow. By combining it with labels, formatting, conditional output, and process context, you can get precise insights into your program&apos;s behavior, all without leaving your editor.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/pattern&quot;&gt;#pattern&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/elixir&quot;&gt;#elixir&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-17T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/advanced-debugging-in-elixir-with-io-inspect</id>
    <title>🐥 Advanced debugging in Elixir with IO.inspect</title>
    <updated>2026-08-17T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/my-thoughts-on-the-future-of-go-in-the-ai-era" rel="alternate"/>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;What if the rise of AI makes programming languages like Go more valuable? Alex Pliutau argues that while languages like TypeScript and Rust dominate the discussion, Go offers unique advantages in the AI landscape. With its massive standard library, fast compilation, and commitment to stability, Go could thrive even as coding agents proliferate. This article dissects Go&apos;s critical features—like explicit error handling and efficient concurrency—that align well with AI-generated code needs. Pliutau suggests that Go&apos;s simplicity and practicality may become invaluable in an era where comprehensible and maintainable code is essential.&lt;/p&gt;
&lt;/blockquote&gt;&lt;p&gt;&lt;a href=&quot;https://packagemain.tech/p/my-thoughts-on-the-future-of-go-in-ai-era&quot;&gt;Continue reading on &lt;strong&gt;packagemain.tech&lt;/strong&gt;&lt;/a&gt;&lt;p&gt;&lt;/p&gt;</content>
    <published>2026-08-15T13:00:00Z</published>
    <id>https://www.yellowduck.be/posts/my-thoughts-on-the-future-of-go-in-the-ai-era</id>
    <title>🔗 My thoughts on the future of Go in the AI era</title>
    <updated>2026-08-15T13:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/hosting-your-own-apt-repository-for-a-go-cli-tool" rel="alternate"/>
    <content type="html">&lt;p&gt;Distributing a command-line tool to Linux users is straightforward until you want to make it feel native. Tarballs work, but APT packages are what seasoned Linux users expect: a single &lt;code&gt;apt install&lt;/code&gt;, automatic updates via &lt;code&gt;apt upgrade&lt;/code&gt;, and clean removal with &lt;code&gt;apt remove&lt;/code&gt;. This post walks through how we set up a self-hosted APT repository for a Go CLI tool using only a Makefile, a small shell script, and a clever Go utility called &lt;code&gt;aptblob&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;The goal&lt;/h1&gt;
&lt;p&gt;We want users to be able to run:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;curl -fsSL https://packages.example.com/pubkey.gpg | sudo apt-key add -
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;echo &quot;deb https://packages.example.com stable main&quot; | sudo tee /etc/apt/sources.list.d/mycli.list
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;sudo apt update
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;sudo apt install mycli&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To make that work, we need to produce properly structured &lt;code&gt;.deb&lt;/code&gt; packages for each architecture, sign them with a GPG key, and publish an APT repository index that &lt;code&gt;apt&lt;/code&gt; can parse.&lt;/p&gt;
&lt;h1&gt;Step 1: cross-compiling and packaging&lt;/h1&gt;
&lt;p&gt;Go makes cross-compilation trivial. The &lt;code&gt;build-deb&lt;/code&gt; make target compiles the binary twice — once for &lt;code&gt;arm64&lt;/code&gt; and once for &lt;code&gt;amd64&lt;/code&gt; — and wraps each in a proper Debian package.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-make&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;## build-deb: build the debian package
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;.PHONY: build-deb
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;build-deb:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    @rm -f ./$(APPNAME)-*
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    $(call build-binary,linux,arm64,arm64)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    $(call build-deb,arm64)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    $(call build-binary,linux,amd64,x86_64)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    $(call build-deb,amd64)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;build-binary&lt;/code&gt; helper looks like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-make&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;define build-binary
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;    @GOOS=$(1) GOARCH=$(2) go build -o $(APPNAME)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;endef&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;build-deb&lt;/code&gt; helper function does all the Debian packaging work. It creates the directory structure that &lt;code&gt;dpkg-deb&lt;/code&gt; expects, writes a minimal &lt;code&gt;DEBIAN/control&lt;/code&gt; file, places the binary into &lt;code&gt;usr/bin/&lt;/code&gt;, and calls &lt;code&gt;dpkg-deb --build&lt;/code&gt; to produce the &lt;code&gt;.deb&lt;/code&gt; archive:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-make&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;define build-deb
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;    @mkdir -p $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    @mkdir -p $(APPNAME)_$(VERSION)-1_$(1)/usr/bin
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    @mv $(APPNAME) $(APPNAME)_$(VERSION)-1_$(1)/usr/bin/$(APPNAME)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    @echo &quot;Package: mycli&quot;              &gt; $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    @echo &quot;Version: $(VERSION)&quot;        &gt;&gt; $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    @echo &quot;Maintainer: Team &lt;hi@example.com&gt;&quot; &gt;&gt; $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    @echo &quot;Architecture: $(1)&quot;         &gt;&gt; $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    @echo &quot;Description: My CLI tool&quot;   &gt;&gt; $(APPNAME)_$(VERSION)-1_$(1)/DEBIAN/control
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    @dpkg-deb --build -Zgzip $(APPNAME)_$(VERSION)-1_$(1)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    @rm -rf $(APPNAME)_$(VERSION)-1_$(1)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;endef&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-Zgzip&lt;/code&gt; flag tells &lt;code&gt;dpkg-deb&lt;/code&gt; to use gzip compression, which produces slightly larger packages than xz but is more universally compatible.&lt;/p&gt;
&lt;p&gt;The version is injected automatically from the latest git tag:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-make&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;VERSION := $(shell git describe --tags --abbrev=0)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So tagging a release in git is all it takes to produce the correct package version.&lt;/p&gt;
&lt;h1&gt;Step 2: the &lt;code&gt;aptblob&lt;/code&gt; tool&lt;/h1&gt;
&lt;p&gt;The real hero of this setup is &lt;a href=&quot;https://pkg.go.dev/zombiezen.com/go/aptblob&quot;&gt;&lt;code&gt;zombiezen.com/go/aptblob&lt;/code&gt;&lt;/a&gt;. It is a small Go utility that creates and maintains APT repositories without requiring you to install &lt;code&gt;reprepro&lt;/code&gt;, &lt;code&gt;aptly&lt;/code&gt;, or any other heavyweight tooling. Because it is fetched and run via &lt;code&gt;go run&lt;/code&gt;, there is no separate installation step — Go&apos;s module system handles it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;aptblob&lt;/code&gt; supports multiple storage backends via URL: &lt;code&gt;file://&lt;/code&gt; for local disk, and cloud storage providers for production hosting.&lt;/p&gt;
&lt;h1&gt;Step 3: initialising the repository&lt;/h1&gt;
&lt;p&gt;Before uploading any packages, the repository needs to be bootstrapped with an &lt;code&gt;InRelease&lt;/code&gt; file that describes the repository metadata. This is handled by &lt;code&gt;init-repo.sh&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;#!/usr/bin/env bash
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;go run zombiezen.com/go/aptblob@latest init -k $KEY_ID &quot;file://`pwd`/apt-repo&quot; stable &lt;&lt;EOF
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;Origin: stable
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;Label: My CLI Repository
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;Suite: stable
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;Codename: stable
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;Version: 1.0
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;Architectures: arm64 amd64
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;Components: main
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;Description: The My CLI software repository
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;EOF&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-k $KEY_ID&lt;/code&gt; flag tells &lt;code&gt;aptblob&lt;/code&gt; to sign the repository metadata with a specific GPG key. The key ID is passed via the &lt;code&gt;APT_SIGNING_KEY_ID&lt;/code&gt; environment variable so it never appears in source control. The &lt;code&gt;stable&lt;/code&gt; argument at the end is the distribution name — the string users put in their &lt;code&gt;sources.list&lt;/code&gt; entry.&lt;/p&gt;
&lt;h1&gt;Step 4: uploading the packages&lt;/h1&gt;
&lt;p&gt;With the repository initialised, the &lt;code&gt;build-apt-repo&lt;/code&gt; target uploads both &lt;code&gt;.deb&lt;/code&gt; files:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-make&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;## build-apt-repo: build the apt repository
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;build-apt-repo: build-deb
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    @echo &quot;Building apt repository for version $(VERSION)&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    @rm -rf apt-repo
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    @mkdir apt-repo
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    @KEY_ID=$(KEY_ID) bash init-repo.sh
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    @go run zombiezen.com/go/aptblob@latest upload -k $(KEY_ID) \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;        &quot;file://$(shell pwd)/apt-repo&quot; stable $(APPNAME)_$(VERSION)-1_arm64.deb
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    @go run zombiezen.com/go/aptblob@latest upload -k $(KEY_ID) \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;        &quot;file://$(shell pwd)/apt-repo&quot; stable $(APPNAME)_$(VERSION)-1_amd64.deb
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    @rm *.deb&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;upload&lt;/code&gt; call:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Copies the &lt;code&gt;.deb&lt;/code&gt; into the repository&apos;s pool directory&lt;/li&gt;
&lt;li&gt;Updates the &lt;code&gt;Packages&lt;/code&gt; index files for the relevant architecture&lt;/li&gt;
&lt;li&gt;Regenerates and re-signs the &lt;code&gt;Release&lt;/code&gt; / &lt;code&gt;InRelease&lt;/code&gt; metadata&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &lt;code&gt;-k $(KEY_ID)&lt;/code&gt; flag is required on every upload because &lt;code&gt;aptblob&lt;/code&gt; must re-sign the repository index each time a package is added.&lt;/p&gt;
&lt;p&gt;After the upload, the temporary &lt;code&gt;.deb&lt;/code&gt; files are cleaned up — the canonical copies now live inside &lt;code&gt;apt-repo/&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;Step 5: publishing&lt;/h1&gt;
&lt;p&gt;The &lt;code&gt;apt-repo/&lt;/code&gt; directory is a fully self-contained, static APT repository. Serving it is as simple as putting it behind any HTTP server or syncing it to a cloud storage bucket with public read access:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;# Sync to S3 (or any S3-compatible service)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;aws s3 sync ./apt-repo s3://my-packages-bucket/ --delete
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;# Or rsync to a VPS
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;rsync -avz ./apt-repo/ user@packages.example.com:/var/www/packages/&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Why this approach works well&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;No daemon or database.&lt;/strong&gt; Traditional APT repository tools like &lt;code&gt;reprepro&lt;/code&gt; maintain a local database and run as a long-lived process. &lt;code&gt;aptblob&lt;/code&gt; is entirely stateless — the repository metadata &lt;em&gt;is&lt;/em&gt; the state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pure Go toolchain.&lt;/strong&gt; The only external dependencies are &lt;code&gt;gpg&lt;/code&gt; (for signing) and &lt;code&gt;dpkg-deb&lt;/code&gt; (for building the &lt;code&gt;.deb&lt;/code&gt; files). Everything else is managed by &lt;code&gt;go run&lt;/code&gt;, which means CI machines don&apos;t need any special APT repository software pre-installed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reproducible builds.&lt;/strong&gt; Every release starts from a clean &lt;code&gt;rm -rf apt-repo&lt;/code&gt;, so there is no risk of stale packages accumulating silently.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Works with any storage.&lt;/strong&gt; Today we use &lt;code&gt;file://&lt;/code&gt; for a local build; tomorrow we can switch to an S3 or GCS URL without changing anything else.&lt;/p&gt;
&lt;h1&gt;The full release workflow&lt;/h1&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;# 1. Tag the release
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;git tag v1.2.3 &amp;&amp; git push --tags
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;# 2. Build and publish
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;APT_SIGNING_KEY_ID=ABCDEF1234567890 make build-apt-repo
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;aws s3 sync ./apt-repo s3://my-packages-bucket/ --delete&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s two commands from tag to published APT package. For a small CLI tool, it is hard to imagine a lighter-weight setup that still delivers a genuine &lt;code&gt;apt install&lt;/code&gt; experience.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it produces&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cross-compile&lt;/td&gt;
&lt;td&gt;&lt;code&gt;go build&lt;/code&gt; with &lt;code&gt;GOOS&lt;/code&gt;/&lt;code&gt;GOARCH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Linux binaries (arm64, amd64)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dpkg-deb --build&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.deb&lt;/code&gt; archives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Init repo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aptblob init&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signed &lt;code&gt;InRelease&lt;/code&gt; / &lt;code&gt;Release&lt;/code&gt; metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload packages&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aptblob upload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Updated &lt;code&gt;Packages&lt;/code&gt; index + pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serve&lt;/td&gt;
&lt;td&gt;Any static HTTP server&lt;/td&gt;
&lt;td&gt;Installable APT repository&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If you are shipping a Go CLI and want proper APT distribution without the overhead of a full packaging pipeline, &lt;code&gt;aptblob&lt;/code&gt; plus a handful of Makefile targets gets you there in under 50 lines.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/golang&quot;&gt;#golang&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/tools&quot;&gt;#tools&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/devops&quot;&gt;#devops&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/linux&quot;&gt;#linux&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-14T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/hosting-your-own-apt-repository-for-a-go-cli-tool</id>
    <title>🐥 Hosting your own APT repository for a Go CLI tool</title>
    <updated>2026-08-14T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/distributing-a-go-cli-via-a-homebrew-tap" rel="alternate"/>
    <content type="html">&lt;p&gt;Homebrew is the package manager that macOS developers reach for first. If you want your CLI tool to feel like a first-class citizen on macOS — installable with a single command, updatable with &lt;code&gt;brew upgrade&lt;/code&gt;, and auto-completed by the shell — a Homebrew tap is the right solution. This post covers how to set one up for a Go CLI, including the formula, CI validation, and the update workflow.&lt;/p&gt;
&lt;h1&gt;What is a tap?&lt;/h1&gt;
&lt;p&gt;A Homebrew &lt;em&gt;tap&lt;/em&gt; is just a GitHub repository named &lt;code&gt;homebrew-&lt;something&gt;&lt;/code&gt;. Once a user runs &lt;code&gt;brew tap org/something&lt;/code&gt;, Homebrew knows to look for formulas in that repo. From that point on, &lt;code&gt;brew install org/something/mycli&lt;/code&gt; works exactly like installing any official Homebrew package.&lt;/p&gt;
&lt;p&gt;The naming convention is the only magic: no registration, no approval process. Anyone can host a tap.&lt;/p&gt;
&lt;h1&gt;The formula&lt;/h1&gt;
&lt;p&gt;Formulas live in a &lt;code&gt;Formula/&lt;/code&gt; directory and are written in Ruby using Homebrew&apos;s DSL. For a pre-built Go binary, the formula is remarkably simple:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-ruby&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;class MyCli &lt; Formula
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  desc &quot;CLI interface for My App&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  homepage &quot;https://example.com&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  url &quot;https://cdn.example.com/mycli/5c7b6ef47a682ffe.../mycli-macos.tar.gz&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  version &quot;1.47.0&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  sha256 &quot;48e146382b0f527549328da4bed9d90f2d584e1b05a8f94846cf47c3c66a9343&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  license &quot;MIT&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;  def install
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    bin.install &quot;mycli&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;  test do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    system &quot;#{bin}/mycli&quot;, &quot;--version&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are only four things that change on each release:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt; — points to the new tarball on the CDN&lt;/li&gt;
&lt;li&gt;&lt;code&gt;version&lt;/code&gt; — the semver string shown by &lt;code&gt;brew info&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sha256&lt;/code&gt; — the checksum Homebrew verifies before unpacking&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;install&lt;/code&gt; block copies the binary into the Homebrew prefix (&lt;code&gt;/opt/homebrew/bin/&lt;/code&gt; on Apple Silicon, &lt;code&gt;/usr/local/bin/&lt;/code&gt; on Intel). The &lt;code&gt;test&lt;/code&gt; block is a smoke test Homebrew runs after install — here, just confirming the binary exits cleanly when asked for its version.&lt;/p&gt;
&lt;h1&gt;Building a universal macOS binary&lt;/h1&gt;
&lt;p&gt;The URL points to a &lt;code&gt;.tar.gz&lt;/code&gt; containing a single binary. Rather than shipping separate arm64 and x86_64 tarballs and maintaining two formula entries, we use &lt;code&gt;lipo&lt;/code&gt; to merge them into one universal binary:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-make&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;define build-binary-mac
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;    @echo &quot;Building for macos&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    GOOS=darwin GOARCH=arm64 go build -o mycli-arm .
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    GOOS=darwin GOARCH=amd64 go build -o mycli-x86 .
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    lipo -create -output mycli mycli-x86 mycli-arm
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    tar czf ./mycli-macos.tar.gz mycli
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    rm -f mycli-x86 mycli-arm mycli
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;endef&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GOOS=darwin GOARCH=arm64 go build&lt;/code&gt; and &lt;code&gt;GOOS=darwin GOARCH=amd64 go build&lt;/code&gt; produce two binaries. &lt;code&gt;lipo -create&lt;/code&gt; stitches them into a Fat Binary that macOS automatically runs under the native architecture. The result: one tarball, one formula URL, works on both Apple Silicon and Intel Macs without any user-facing complexity.&lt;/p&gt;
&lt;h1&gt;The CDN URL strategy&lt;/h1&gt;
&lt;p&gt;The binary is uploaded to object storage (DigitalOcean Spaces in our case, but S3 or any public CDN works equally well). The URL path includes the git commit SHA:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;https://cdn.example.com/mycli/{git-sha}/mycli-macos.tar.gz&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Embedding the commit SHA rather than the version tag means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Uploads are naturally immutable — the same SHA always points to the same build&lt;/li&gt;
&lt;li&gt;Multiple release candidates for the same version cannot collide&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;create-index.py&lt;/code&gt; script (which generates a &lt;code&gt;index.json&lt;/code&gt; listing all build artifacts) uses the same SHA, keeping the release artefacts coherent&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Updating the formula&lt;/h1&gt;
&lt;p&gt;When a new version ships:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Build and upload the tarball: &lt;code&gt;make build-all&lt;/code&gt; produces &lt;code&gt;mycli-macos.tar.gz&lt;/code&gt;; CI uploads it to the CDN under the new commit SHA.&lt;/li&gt;
&lt;li&gt;Compute the SHA256 of the tarball: &lt;code&gt;sha256sum mycli-macos.tar.gz&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Update three lines in the formula: &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;sha256&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Open a PR against the tap repository&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The tap CI then validates the change before it merges (more on that below).&lt;/p&gt;
&lt;h1&gt;CI: validating formulas with &lt;code&gt;brew test-bot&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;The tap repo includes a GitHub Actions workflow that runs on every push and pull request:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;name: brew test-bot
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;on:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  push:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    branches: [main]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  pull_request:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;jobs:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;  test-bot:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    strategy:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;      matrix:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;        os: [ubuntu-latest, macos-latest]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;    runs-on: ${{ matrix.os }}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    steps:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;      - uses: Homebrew/actions/setup-homebrew@main
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;      - run: brew test-bot --only-cleanup-before
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;      - run: brew test-bot --only-setup
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;      - run: brew test-bot --only-tap-syntax
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;      - run: brew test-bot --only-formulae
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;        if: github.event_name == &amp;#39;pull_request&amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;      - name: Upload bottles as artifact
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;        if: always() &amp;&amp; github.event_name == &amp;#39;pull_request&amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;        uses: actions/upload-artifact@main
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;        with:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;26&quot;&gt;          name: bottles
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;27&quot;&gt;          path: &amp;#39;*.bottle.*&amp;#39;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;brew test-bot&lt;/code&gt; is Homebrew&apos;s own CI harness. The steps do progressively more:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What it checks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--only-tap-syntax&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ruby syntax, formula naming conventions, &lt;code&gt;brew audit&lt;/code&gt; rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--only-formulae&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Actually installs the formula and runs the &lt;code&gt;test do&lt;/code&gt; block&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Running on both &lt;code&gt;ubuntu-latest&lt;/code&gt; and &lt;code&gt;macos-latest&lt;/code&gt; catches issues specific to either platform — important because Homebrew has first-class Linux support (&lt;code&gt;Linuxbrew&lt;/code&gt;) and many teams use it in Docker-based CI.&lt;/p&gt;
&lt;h1&gt;CI: Merging PRs with &lt;code&gt;brew pr-pull&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;The second workflow handles the merge step:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;name: brew pr-pull
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;on:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  pull_request_target:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    types: [labeled]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;jobs:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  pr-pull:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    if: contains(github.event.pull_request.labels.*.name, &amp;#39;pr-pull&amp;#39;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    runs-on: ubuntu-22.04
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    steps:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;      - uses: Homebrew/actions/setup-homebrew@main
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;      - uses: Homebrew/actions/git-user-config@main
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;      - name: Pull bottles
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;        env:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;          HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;          PULL_REQUEST: ${{ github.event.pull_request.number }}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;        run: |
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;          brew pr-pull --debug --tap=&quot;$GITHUB_REPOSITORY&quot; &quot;$PULL_REQUEST&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;      - uses: Homebrew/actions/git-try-push@main
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;        with:
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;          token: ${{ github.token }}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;          branch: main
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;26&quot;&gt;      - name: Delete branch
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;27&quot;&gt;        if: github.event.pull_request.head.repo.fork == false
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;28&quot;&gt;        run: git push --delete origin &quot;$BRANCH&quot;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of merging PRs normally, a maintainer applies the &lt;code&gt;pr-pull&lt;/code&gt; label. &lt;code&gt;brew pr-pull&lt;/code&gt; then:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Downloads any pre-built bottles attached to the PR as GitHub Actions artifacts&lt;/li&gt;
&lt;li&gt;Commits the bottle checksums into the formula&lt;/li&gt;
&lt;li&gt;Pushes directly to &lt;code&gt;main&lt;/code&gt; and deletes the PR branch&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This mirrors the workflow used by the official Homebrew/homebrew-core tap and means the merge is always handled by Homebrew&apos;s own tooling rather than GitHub&apos;s merge button.&lt;/p&gt;
&lt;p&gt;For a simple pre-built binary formula (no compilation step, no bottle needed), this is mostly ceremony — but it is good practice because it keeps the tap workflow consistent with the broader Homebrew ecosystem and makes it easy to add bottles later if the build-from-source path is ever needed.&lt;/p&gt;
&lt;h1&gt;The user experience&lt;/h1&gt;
&lt;p&gt;After all of this, the user-facing story is clean:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;# One-time setup
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;brew tap myorg/mycli
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;# Install
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;brew install mycli
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;# Upgrade when a new version ships
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;brew upgrade mycli&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And because Homebrew auto-discovers shell completions in &lt;code&gt;share/zsh/site-functions/&lt;/code&gt;, &lt;code&gt;share/bash-completion/&lt;/code&gt;, and similar paths, completions are picked up automatically if the binary generates them into those locations at install time.&lt;/p&gt;
&lt;h1&gt;Summary&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;homebrew-mycli&lt;/code&gt; repo&lt;/td&gt;
&lt;td&gt;The tap — a public GitHub repo Homebrew reads formulas from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Formula/mycli.rb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Declares the URL, version, checksum, install path, and smoke test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Universal binary (&lt;code&gt;lipo&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;One tarball covers both Apple Silicon and Intel in a single formula entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN + commit SHA URL&lt;/td&gt;
&lt;td&gt;Immutable, content-addressed artifact storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brew test-bot&lt;/code&gt; CI&lt;/td&gt;
&lt;td&gt;Validates formula syntax and install on every PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brew pr-pull&lt;/code&gt; CI&lt;/td&gt;
&lt;td&gt;Homebrew-native merge flow that handles bottle attachment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The tap repository itself is under 20 lines of real content. Most of the work is a one-time setup; after that, each release is a three-line diff to the formula and a label click to merge.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/golang&quot;&gt;#golang&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/tools&quot;&gt;#tools&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/mac&quot;&gt;#mac&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-08T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/distributing-a-go-cli-via-a-homebrew-tap</id>
    <title>🐥 Distributing a Go CLI via a Homebrew tap</title>
    <updated>2026-08-08T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/adding-2fa-to-phoenix-liveview-with-phx-gen-auth" rel="alternate"/>
    <content type="html">&lt;p&gt;Two-factor authentication (2FA) is a great way to improve account security. This post walks through how to add TOTP-based 2FA to a Phoenix LiveView app using the built-in &lt;code&gt;phx.gen.auth&lt;/code&gt; authentication system.&lt;/p&gt;
&lt;p&gt;We’ll cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Adding required fields and dependencies&lt;/li&gt;
&lt;li&gt;2FA setup flow using LiveView&lt;/li&gt;
&lt;li&gt;TOTP challenge during login&lt;/li&gt;
&lt;li&gt;LiveView-specific login handling&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Add dependencies&lt;/h2&gt;
&lt;p&gt;Add the following libraries to &lt;code&gt;mix.exs&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;defp deps do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  [
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    {:nimble_totp, &quot;~&gt; 1.0&quot;},
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    {:qr_code, &quot;~&gt; 2.2&quot;}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  ]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then run:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;mix deps.get&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Extend the user schema&lt;/h2&gt;
&lt;p&gt;Add two fields to your &lt;code&gt;users&lt;/code&gt; table:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;# priv/repo/migrations/*_add_totp_to_users.exs
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;alter table(:users) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  add :totp_secret, :string
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  add :totp_confirmed_at, :utc_datetime
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Migrate:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;mix ecto.migrate&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In your user schema:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;schema &quot;users&quot; do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  field :totp_secret, :string
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  field :totp_confirmed_at, :utc_datetime
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  # ...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ensure your &lt;code&gt;changeset/2&lt;/code&gt; casts these fields:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;def changeset(user, attrs) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  user
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  |&gt; cast(attrs, [:email, :totp_secret, :totp_confirmed_at])
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  |&gt; validate_required([:email])
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Add 2FA setup LiveView&lt;/h2&gt;
&lt;p&gt;Create a new LiveView at &lt;code&gt;/settings/two_factor&lt;/code&gt; that generates a secret, renders a QR code, and lets the user enter their TOTP code.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;defmodule MyAppWeb.TwoFactorSetupLive do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  use MyAppWeb, :live_view
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  alias NimbleTOTP
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  alias QRCode
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  alias MyApp.Accounts
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  def mount(_params, _session, socket) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    user = socket.assigns.current_user
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    if user.totp_secret do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;      {:ok, assign(socket, setup?: false)}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;    else
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;      secret = Base.encode32(NimbleTOTP.secret())
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;      uri = NimbleTOTP.otpauth_uri(&quot;MyApp:#{user.email}&quot;, Base.decode32!(secret), issuer: &quot;MyApp&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;      {:ok, png} = QRCode.create(uri, :png)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;      b64 = Base.encode64(png)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;      {:ok,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;       assign(socket,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;         setup?: true,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;         secret: secret,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;         qr_code: &quot;data:image/png;base64,#{b64}&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;         code: &quot;&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;         error: nil
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;       )}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;26&quot;&gt;    end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;27&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;28&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;29&quot;&gt;  def handle_event(&quot;verify&quot;, %{&quot;code&quot; =&gt; code}, socket) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;30&quot;&gt;    secret = Base.decode32!(socket.assigns.secret)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;31&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;32&quot;&gt;    if NimbleTOTP.valid?(secret, code) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;33&quot;&gt;      {:ok, _} =
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;34&quot;&gt;        Accounts.update_user(socket.assigns.current_user, %{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;35&quot;&gt;          totp_secret: socket.assigns.secret,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;36&quot;&gt;          totp_confirmed_at: DateTime.utc_now()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;37&quot;&gt;        })
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;38&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;39&quot;&gt;      {:noreply,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;40&quot;&gt;       socket
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;41&quot;&gt;       |&gt; put_flash(:info, &quot;2FA enabled.&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;42&quot;&gt;       |&gt; push_redirect(to: &quot;/settings&quot;)}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;43&quot;&gt;    else
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;44&quot;&gt;      {:noreply, assign(socket, error: &quot;Invalid code&quot;)}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;45&quot;&gt;    end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;46&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;47&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-heex&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;!-- templates/two_factor_setup_live.html.heex --&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;h1&gt;Two-Factor Authentication&lt;/h1&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;&lt;%= if @setup? do %&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  &lt;p&gt;Scan this QR code in your authenticator app:&lt;/p&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  &lt;img src={@qr_code} /&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;  &lt;form phx-submit=&quot;verify&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    &lt;label&gt;Code:&lt;/label&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;    &lt;input type=&quot;text&quot; name=&quot;code&quot; /&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    &lt;button&gt;Verify&lt;/button&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;  &lt;/form&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;  &lt;%= if @error, do: content_tag(:p, @error, class: &quot;text-red-500&quot;) %&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;&lt;% else %&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;  &lt;p&gt;2FA is already enabled.&lt;/p&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;&lt;% end %&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Add a TOTP challenge LiveView&lt;/h2&gt;
&lt;p&gt;When a user logs in with 2FA enabled, redirect them to a LiveView at &lt;code&gt;/two_factor&lt;/code&gt; to enter their code.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;defmodule MyAppWeb.TwoFactorChallengeLive do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  use MyAppWeb, :live_view
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  alias MyApp.Accounts
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  alias NimbleTOTP
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  alias MyAppWeb.UserAuth
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  def mount(_params, %{&quot;pending_user_id&quot; =&gt; id}, socket) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    {:ok, assign(socket, user: Accounts.get_user!(id), code: &quot;&quot;, error: nil)}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;  def handle_event(&quot;verify&quot;, %{&quot;code&quot; =&gt; code}, socket) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;    user = socket.assigns.user
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    if NimbleTOTP.valid?(Base.decode32!(user.totp_secret), code) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;      {:noreply,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;       socket
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;       |&gt; put_flash(:info, &quot;Logged in with 2FA.&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;       |&gt; UserAuth.log_in_user(user, %{})}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;    else
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;      {:noreply, assign(socket, error: &quot;Invalid code&quot;)}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;    end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;  end
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-heex&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;!-- templates/two_factor_challenge_live.html.heex --&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;h1&gt;Enter your 2FA code&lt;/h1&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;&lt;form phx-submit=&quot;verify&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  &lt;input type=&quot;text&quot; name=&quot;code&quot; /&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  &lt;button&gt;Verify&lt;/button&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;&lt;/form&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;&lt;%= if @error, do: content_tag(:p, @error, class: &quot;text-red-500&quot;) %&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Update login logic&lt;/h2&gt;
&lt;p&gt;After validating the user’s password (in your LiveView or controller), check if TOTP is enabled:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;if user.totp_confirmed_at do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  socket
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;  |&gt; Phoenix.LiveView.put_session(:pending_user_id, user.id)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  |&gt; Phoenix.LiveView.redirect(to: &quot;/two_factor&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;else
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  UserAuth.log_in_user(socket, user, %{})
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In controllers, you’d use &lt;code&gt;put_session(conn, ...)&lt;/code&gt; and &lt;code&gt;redirect(conn, ...)&lt;/code&gt; instead.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Update &lt;code&gt;UserAuth&lt;/code&gt; helper&lt;/h2&gt;
&lt;p&gt;Make sure your &lt;code&gt;UserAuth&lt;/code&gt; module has a LiveView-compatible &lt;code&gt;log_in_user/3&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-elixir&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;def log_in_user(socket, user, _params \\ %{}) do
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  token = MyApp.Accounts.generate_user_session_token(user)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  socket
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  |&gt; Phoenix.LiveView.put_session(:user_token, token)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  |&gt; Phoenix.LiveView.redirect(to: &quot;/&quot;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;end&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Done&lt;/h2&gt;
&lt;p&gt;You now have a working TOTP 2FA flow in Phoenix LiveView:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Users opt in to 2FA in their settings&lt;/li&gt;
&lt;li&gt;A QR code is shown and confirmed&lt;/li&gt;
&lt;li&gt;2FA is required on next login&lt;/li&gt;
&lt;li&gt;All handled with clean LiveView flows&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From here, you can extend the system with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Disabling 2FA&lt;/li&gt;
&lt;li&gt;Backup codes&lt;/li&gt;
&lt;li&gt;Remembering trusted devices&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let me know if you&apos;d like a follow-up on any of those features.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/elixir&quot;&gt;#elixir&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/phoenix&quot;&gt;#phoenix&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/auth&quot;&gt;#auth&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-08-03T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/adding-2fa-to-phoenix-liveview-with-phx-gen-auth</id>
    <title>🐥 Adding 2FA to Phoenix LiveView with phx.gen.auth</title>
    <updated>2026-08-03T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/finding-unexpected-http-responses-in-json-logs-with-jq" rel="alternate"/>
    <content type="html">&lt;p&gt;When debugging an application in production, I often want a quick overview of requests that didn&apos;t result in a &quot;normal&quot; HTTP response. If your web server writes JSON logs, &lt;code&gt;jq&lt;/code&gt; makes this incredibly easy.&lt;/p&gt;
&lt;p&gt;Here&apos;s a command I regularly use:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;cat access_*.log \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  | jq -r &amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;      select(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;        .status != 200 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;        .status != 206 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;        .status != 302 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        .status != 304 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;        .status != 308 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;        .status != 101
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;      )
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;      | [.status, .request.uri]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;      | @csv
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    &amp;#39; \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;  | uniq&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output looks something like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;404,&quot;/api/v1/users/...&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;403,&quot;/admin/...&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;429,&quot;/api/v1/search&quot;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;500,&quot;/api/v1/orders/...&quot;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;What this does&lt;/h1&gt;
&lt;p&gt;The command performs three simple steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reads all matching access log files.&lt;/li&gt;
&lt;li&gt;Filters out the expected HTTP status codes (&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;206&lt;/code&gt;, &lt;code&gt;302&lt;/code&gt;, &lt;code&gt;304&lt;/code&gt;, &lt;code&gt;308&lt;/code&gt;, and &lt;code&gt;101&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Outputs the remaining status code together with the requested URI as CSV.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Finally, &lt;code&gt;uniq&lt;/code&gt; removes duplicate entries so you get a concise overview instead of thousands of repeated requests.&lt;/p&gt;
&lt;h1&gt;Why this is useful&lt;/h1&gt;
&lt;p&gt;This is an easy way to spot things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Missing routes (&lt;code&gt;404&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Authorization issues (&lt;code&gt;401&lt;/code&gt;/&lt;code&gt;403&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Rate limiting (&lt;code&gt;429&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Unexpected server errors (&lt;code&gt;500&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Any other response that deserves investigation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead of searching through millions of log lines, you immediately get a list of unique problem endpoints.&lt;/p&gt;
&lt;h1&gt;A small improvement&lt;/h1&gt;
&lt;p&gt;If your logs aren&apos;t already grouped, consider sorting before calling &lt;code&gt;uniq&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;cat access_*.log \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  | jq -r &amp;#39;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;      select(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;        .status != 200 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;        .status != 206 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;        .status != 302 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        .status != 304 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;        .status != 308 and
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;        .status != 101
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;      )
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;      | [.status, .request.uri]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;      | @csv
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    &amp;#39; \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;  | sort \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;  | uniq&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or, even shorter:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;...
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;| sort -u&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s a simple one-liner, but it&apos;s become one of my favourite ways to quickly identify unexpected responses in production logs.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/tools&quot;&gt;#tools&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/logging&quot;&gt;#logging&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/http&quot;&gt;#http&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-07-28T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/finding-unexpected-http-responses-in-json-logs-with-jq</id>
    <title>🐥 Finding unexpected HTTP responses in JSON logs with jq</title>
    <updated>2026-07-28T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/katagelophobia-and-cyber-security-when-fear-of-ridicule-becomes-a-vulnerability" rel="alternate"/>
    <content type="html">&lt;p&gt;Katagelophobia — the fear of being laughed at — is rarely discussed in technical circles, yet it quietly shapes how people behave in security-critical situations. In cyber security, where human judgment is often the last line of defense, this fear can become an unexpected attack surface.&lt;/p&gt;
&lt;h1&gt;The hidden driver behind silence&lt;/h1&gt;
&lt;p&gt;Security incidents are often preceded by small signals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A suspicious email that “looks off”&lt;/li&gt;
&lt;li&gt;An unexpected MFA prompt&lt;/li&gt;
&lt;li&gt;A system behaving slightly differently than usual&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In an ideal environment, users report these signals immediately. In reality, many hesitate. Katagelophobia plays a role here: people avoid speaking up because they fear being wrong, overreacting, or being perceived as inexperienced.&lt;/p&gt;
&lt;p&gt;This creates a dangerous dynamic: attackers rely on hesitation.&lt;/p&gt;
&lt;h1&gt;Social engineering thrives on psychological pressure&lt;/h1&gt;
&lt;p&gt;Modern phishing and social engineering attacks are designed to exploit emotion, not logic. Urgency, authority, and fear are well-known tactics, but fear of embarrassment is equally powerful.&lt;/p&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“This is urgent, don’t escalate”&lt;/li&gt;
&lt;li&gt;“Only you can fix this quickly”&lt;/li&gt;
&lt;li&gt;“Please don’t involve others yet”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These cues discourage validation and collaboration. A user already prone to avoiding ridicule is more likely to comply silently rather than question the request.&lt;/p&gt;
&lt;h1&gt;Organizational culture as a security control&lt;/h1&gt;
&lt;p&gt;Technical defenses can’t compensate for a culture where people are afraid to ask questions.&lt;/p&gt;
&lt;p&gt;Teams that unintentionally reward “knowing everything” or penalize mistakes create an environment where katagelophobia flourishes. The result:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Underreporting of incidents&lt;/li&gt;
&lt;li&gt;Delayed response times&lt;/li&gt;
&lt;li&gt;Increased dwell time for attackers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In contrast, strong security cultures normalize uncertainty:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“If in doubt, report it” is actively reinforced&lt;/li&gt;
&lt;li&gt;False positives are treated as learning opportunities&lt;/li&gt;
&lt;li&gt;Junior and non-technical staff feel safe raising concerns&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Designing systems that reduce fear&lt;/h1&gt;
&lt;p&gt;You can’t eliminate psychological traits, but you can design around them.&lt;/p&gt;
&lt;p&gt;Practical approaches:&lt;/p&gt;
&lt;h2&gt;1. Lower the cost of being wrong&lt;/h2&gt;
&lt;p&gt;Make reporting trivial and low-friction:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One-click “Report phishing” buttons&lt;/li&gt;
&lt;li&gt;Dedicated Slack/Teams channels&lt;/li&gt;
&lt;li&gt;No requirement to justify suspicion&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The easier it is, the less overthinking occurs.&lt;/p&gt;
&lt;h2&gt;2. Remove judgment from feedback loops&lt;/h2&gt;
&lt;p&gt;Avoid responses like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“This was obviously safe”&lt;/li&gt;
&lt;li&gt;“You should have known this”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Thank the report&lt;/li&gt;
&lt;li&gt;Explain briefly&lt;/li&gt;
&lt;li&gt;Reinforce that reporting was correct behavior&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. Simulate safely&lt;/h2&gt;
&lt;p&gt;Phishing simulations shouldn’t shame users. If people feel tested rather than trained, katagelophobia increases.&lt;/p&gt;
&lt;p&gt;Focus on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Education over scoring&lt;/li&gt;
&lt;li&gt;Trends over individual performance&lt;/li&gt;
&lt;li&gt;Private feedback instead of public metrics&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. Lead by example&lt;/h2&gt;
&lt;p&gt;When senior engineers or leadership openly admit uncertainty or mistakes, it sets a powerful precedent.&lt;/p&gt;
&lt;p&gt;Security improves when saying “I’m not sure” becomes acceptable.&lt;/p&gt;
&lt;h1&gt;The human layer is not optional&lt;/h1&gt;
&lt;p&gt;Cyber security discussions often focus on zero-days, encryption, and infrastructure hardening. Yet many breaches still start with a simple human interaction.&lt;/p&gt;
&lt;p&gt;Katagelophobia highlights a key reality: people don’t just fail because they lack knowledge — they fail because of social pressure.&lt;/p&gt;
&lt;p&gt;Addressing that pressure is not “soft” work. It’s a core part of building resilient systems.&lt;/p&gt;
&lt;h1&gt;Closing thought&lt;/h1&gt;
&lt;p&gt;Attackers exploit whatever works. If fear of ridicule prevents someone from reporting a suspicious email, that fear becomes part of the attack chain.&lt;/p&gt;
&lt;p&gt;Reducing that fear may be one of the simplest — and most overlooked — security improvements you can make.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/auth&quot;&gt;#auth&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-07-25T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/katagelophobia-and-cyber-security-when-fear-of-ridicule-becomes-a-vulnerability</id>
    <title>🐥 Katagelophobia and cyber security: when fear of ridicule becomes a vulnerability</title>
    <updated>2026-07-25T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/request-input-vs-request-string-in-laravel" rel="alternate"/>
    <content type="html">&lt;p&gt;Laravel provides several ways to retrieve input from an HTTP request. Two of the most commonly used methods are &lt;code&gt;input()&lt;/code&gt; and &lt;code&gt;string()&lt;/code&gt;. While they look similar, they have an important difference that can make your code safer and more expressive.&lt;/p&gt;
&lt;h1&gt;&lt;code&gt;input()&lt;/code&gt; returns mixed values&lt;/h1&gt;
&lt;p&gt;The &lt;code&gt;input()&lt;/code&gt; method returns the raw value from the request.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;$name = $request-&gt;input(&amp;#39;name&amp;#39;);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The return type depends entirely on the submitted data:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;string&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;array&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;int&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bool&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;null&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This makes &lt;code&gt;input()&lt;/code&gt; the right choice when you don&apos;t know or don&apos;t care about the exact type, or when you&apos;re expecting something other than a string.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;$ids = $request-&gt;input(&amp;#39;ids&amp;#39;, []);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;$published = $request-&gt;input(&amp;#39;published&amp;#39;, false);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;&lt;code&gt;string()&lt;/code&gt; always returns a Stringable object&lt;/h1&gt;
&lt;p&gt;If you expect textual input, &lt;code&gt;string()&lt;/code&gt; is often the better choice.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;$name = $request-&gt;string(&amp;#39;name&amp;#39;);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of returning a plain string, it returns an &lt;code&gt;Illuminate\Support\Stringable&lt;/code&gt; instance, allowing you to immediately chain string operations.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;$username = $request
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    -&gt;string(&amp;#39;username&amp;#39;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    -&gt;trim()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    -&gt;lower()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    -&gt;replace(&amp;#39; &amp;#39;, &amp;#39;-&amp;#39;);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No need for nested &lt;code&gt;Str::of()&lt;/code&gt; calls or temporary variables.&lt;/p&gt;
&lt;h1&gt;Better type safety&lt;/h1&gt;
&lt;p&gt;One subtle advantage is that &lt;code&gt;string()&lt;/code&gt; guarantees you&apos;re working with a string-like value.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;input()&lt;/code&gt;, it&apos;s easy to accidentally call a string function on an array:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;// Potentially problematic
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;$name = trim($request-&gt;input(&amp;#39;name&amp;#39;));&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;name&lt;/code&gt; unexpectedly contains an array, PHP will throw a type error.&lt;/p&gt;
&lt;p&gt;Using &lt;code&gt;string()&lt;/code&gt; makes your intent explicit:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;$name = $request
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    -&gt;string(&amp;#39;name&amp;#39;)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    -&gt;trim()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    -&gt;toString();&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;When to use which&lt;/h1&gt;
&lt;p&gt;As a general guideline:&lt;/p&gt;
&lt;p&gt;Use &lt;code&gt;input()&lt;/code&gt; when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You expect arrays, booleans, integers, or mixed data.&lt;/li&gt;
&lt;li&gt;You simply need the raw request value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use &lt;code&gt;string()&lt;/code&gt; when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You expect text input.&lt;/li&gt;
&lt;li&gt;You plan to manipulate the value.&lt;/li&gt;
&lt;li&gt;You want more expressive, fluent code.&lt;/li&gt;
&lt;li&gt;You want to make your intent clear to future readers.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;My recommendation&lt;/h1&gt;
&lt;p&gt;For textual fields like names, email addresses, search queries, slugs, or titles, prefer &lt;code&gt;string()&lt;/code&gt;. It communicates that the value is expected to be text and gives you Laravel&apos;s fluent string API for free.&lt;/p&gt;
&lt;p&gt;Reserve &lt;code&gt;input()&lt;/code&gt; for cases where the value may legitimately be another type, such as arrays from multi-select fields, boolean flags, or numeric values.&lt;/p&gt;
&lt;p&gt;It&apos;s a small change, but one that makes your codebase a little more readable, a little safer, and a little more idiomatic.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/php&quot;&gt;#php&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-07-20T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/request-input-vs-request-string-in-laravel</id>
    <title>🐥 Request::input() vs Request::string() in Laravel</title>
    <updated>2026-07-20T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/how-to-start-and-enable-clamav-service-on-linux" rel="alternate"/>
    <content type="html">&lt;p&gt;ClamAV is a powerful, open-source antivirus engine designed to detect malware, viruses, and trojans on Linux systems. If you have just installed it, you need to start its background services so that it can protect your system and keep its virus definitions up to date.&lt;/p&gt;
&lt;p&gt;This guide will show you how to start, enable, and verify the ClamAV services using &lt;code&gt;systemctl&lt;/code&gt;.&lt;/p&gt;
&lt;h1&gt;Prerequisites&lt;/h1&gt;
&lt;p&gt;Before running the commands, ensure you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A Linux distribution installed.&lt;/li&gt;
&lt;li&gt;Administrative privileges (&lt;code&gt;sudo&lt;/code&gt; or &lt;code&gt;root&lt;/code&gt; access).&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Step 1: Start the Antivirus Daemon&lt;/h1&gt;
&lt;p&gt;The primary service is &lt;code&gt;clamav-daemon&lt;/code&gt;. This background process loads virus signatures into memory and handles on-access scanning. It also allows you to run high-speed scans using the &lt;code&gt;clamdscan&lt;/code&gt; utility.&lt;/p&gt;
&lt;p&gt;Run the following commands in your terminal:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Start the service:&lt;/strong&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo systemctl start clamav-daemon&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable it to start automatically on boot:&lt;/strong&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo systemctl enable clamav-daemon&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify that it is running correctly:&lt;/strong&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo systemctl status clamav-daemon&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Note:&lt;/strong&gt; The daemon might take a few moments to change to an &quot;active&quot; status. This delay happens because it is loading a large database of malware definitions directly into your system&apos;s RAM.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;Step 2: Start the Automatic Database Updater&lt;/h1&gt;
&lt;p&gt;An antivirus engine is only as good as its virus signatures. ClamAV uses a separate service called &lt;code&gt;clamav-freshclam&lt;/code&gt; to look for and download the latest malware definitions automatically.&lt;/p&gt;
&lt;p&gt;To get the updater running, execute these commands:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Start the updater service:&lt;/strong&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo systemctl start clamav-freshclam&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable the updater to start on boot:&lt;/strong&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo systemctl enable clamav-freshclam&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify the update status:&lt;/strong&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo systemctl status clamav-freshclam&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;Your Linux machine is now running ClamAV in the background with continuous, automated database updates. You can now safely run manual system scans or configure real-time protection.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/terminal&quot;&gt;#terminal&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/linux&quot;&gt;#linux&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/sysadmin&quot;&gt;#sysadmin&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-07-07T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/how-to-start-and-enable-clamav-service-on-linux</id>
    <title>🐥 How to start and enable ClamAV service on Linux</title>
    <updated>2026-07-07T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/fixing-korean-pdf-rendering-issues-in-poppler-on-ubuntu-22-04" rel="alternate"/>
    <content type="html">&lt;p&gt;Poppler’s &lt;code&gt;pdftoppm&lt;/code&gt; tool is commonly used to render PDFs into images for processing, previews, or conversion pipelines. On Ubuntu 22.04, the default Poppler version (22.02) can run into issues when rendering PDFs that contain Korean text, especially when fonts are missing or incorrectly substituted.&lt;/p&gt;
&lt;p&gt;This post explains a practical fix that avoids upgrading Poppler entirely.&lt;/p&gt;
&lt;h1&gt;The problem&lt;/h1&gt;
&lt;p&gt;When rendering certain Korean PDFs with &lt;code&gt;pdftoppm&lt;/code&gt;, the output may show:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;missing characters (blank boxes or “tofu” glyphs)&lt;/li&gt;
&lt;li&gt;incorrect font substitution&lt;/li&gt;
&lt;li&gt;broken or unreadable text rendering&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is often not caused by Poppler itself, but by missing font configuration and CMap data required for CJK (Chinese, Japanese, Korean) text handling.&lt;/p&gt;
&lt;h1&gt;The root cause&lt;/h1&gt;
&lt;p&gt;Poppler relies on the system font stack and external mapping data:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fontconfig&lt;/strong&gt;: resolves font substitutions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CJK fonts&lt;/strong&gt;: provide actual glyphs for Korean characters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Poppler CMap data&lt;/strong&gt;: maps PDF encodings to Unicode correctly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On a minimal Ubuntu server installation, two key components are often missing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;poppler-data&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;CJK font packages such as Noto Sans CJK&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;The fix&lt;/h1&gt;
&lt;p&gt;Instead of upgrading Poppler, installing the correct font dependencies resolves the issue.&lt;/p&gt;
&lt;h2&gt;1. Install CJK fonts&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo apt install fonts-noto-cjk fonts-noto-core fonts-unifont&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. Install Poppler CMap data&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;sudo apt install poppler-data&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. Rebuild the font cache&lt;/h2&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;fc-cache -fv&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Verify the fix&lt;/h1&gt;
&lt;p&gt;Re-run your rendering command:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;pdftoppm input.pdf output&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Korean text should now render correctly in the generated images.&lt;/p&gt;
&lt;h1&gt;Why this works&lt;/h1&gt;
&lt;p&gt;Poppler 22.x is capable of rendering CJK PDFs correctly, but only when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the system provides appropriate glyph fonts&lt;/li&gt;
&lt;li&gt;CMap mappings are available via &lt;code&gt;poppler-data&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Without these, Poppler falls back to incomplete or incorrect font substitution, leading to broken output.&lt;/p&gt;
&lt;h1&gt;When you actually need a newer Poppler&lt;/h1&gt;
&lt;p&gt;Upgrading Poppler is only necessary if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;rendering logic itself is broken (rare for CJK issues)&lt;/li&gt;
&lt;li&gt;you need specific bug fixes or features in newer releases&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For font-related issues, upgrading is usually unnecessary and introduces avoidable dependency complexity.&lt;/p&gt;
&lt;h1&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;If you encounter broken Korean text rendering in &lt;code&gt;pdftoppm&lt;/code&gt; on Ubuntu 22.04, the fix is typically not a Poppler upgrade but a missing font stack.&lt;/p&gt;
&lt;p&gt;Installing &lt;code&gt;fonts-noto-cjk&lt;/code&gt; and &lt;code&gt;poppler-data&lt;/code&gt; resolves most cases immediately and keeps your system stable without rebuilding core libraries.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/pdf&quot;&gt;#pdf&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/linux&quot;&gt;#linux&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/sysadmin&quot;&gt;#sysadmin&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-07-05T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/fixing-korean-pdf-rendering-issues-in-poppler-on-ubuntu-22-04</id>
    <title>🐥 Fixing Korean PDF rendering issues in Poppler on Ubuntu 22.04</title>
    <updated>2026-07-05T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/the-mysql-null-safe-equality-operator" rel="alternate"/>
    <content type="html">&lt;p&gt;If you&apos;ve worked with MySQL long enough, you&apos;ve probably been bitten by &lt;code&gt;NULL&lt;/code&gt; comparisons at least once. A query that &lt;em&gt;should&lt;/em&gt; return results returns nothing. A &lt;code&gt;WHERE&lt;/code&gt; clause that &lt;em&gt;should&lt;/em&gt; exclude a row doesn&apos;t. The culprit is almost always the three-valued logic of SQL — and the null-safe equality operator &lt;code&gt;&lt;=&gt;&lt;/code&gt; is one of the cleanest tools for dealing with it.&lt;/p&gt;
&lt;h1&gt;The problem with &lt;code&gt;=&lt;/code&gt; and &lt;code&gt;NULL&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;In SQL, &lt;code&gt;NULL&lt;/code&gt; represents the absence of a value — the unknown. Because of this, any comparison involving &lt;code&gt;NULL&lt;/code&gt; using the standard &lt;code&gt;=&lt;/code&gt; operator yields &lt;code&gt;NULL&lt;/code&gt; (not &lt;code&gt;TRUE&lt;/code&gt; or &lt;code&gt;FALSE&lt;/code&gt;), and &lt;code&gt;NULL&lt;/code&gt; is falsy in a &lt;code&gt;WHERE&lt;/code&gt; clause.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;SELECT NULL = NULL;   -- NULL
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;SELECT NULL = 1;      -- NULL
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;SELECT 1 = 1;         -- 1 (TRUE)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;SELECT * FROM users WHERE deleted_at = NULL;  -- returns nothing&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The idiomatic fix is &lt;code&gt;IS NULL&lt;/code&gt; / &lt;code&gt;IS NOT NULL&lt;/code&gt;, but that only works for literal null checks. The moment you&apos;re comparing two columns — one or both of which might be &lt;code&gt;NULL&lt;/code&gt; — things get awkward fast.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;-- This silently drops rows where either column is NULL
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;SELECT * FROM orders WHERE shipping_address = billing_address;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Enter &lt;code&gt;&lt;=&gt;&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;MySQL&apos;s null-safe equality operator &lt;code&gt;&lt;=&gt;&lt;/code&gt; behaves exactly like &lt;code&gt;=&lt;/code&gt;, except it treats &lt;code&gt;NULL&lt;/code&gt; as a comparable value. Two &lt;code&gt;NULL&lt;/code&gt;s are considered equal, and a &lt;code&gt;NULL&lt;/code&gt; compared to any non-null value is &lt;code&gt;FALSE&lt;/code&gt;.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;SELECT NULL &lt;=&gt; NULL;   -- 1 (TRUE)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;SELECT NULL &lt;=&gt; 1;      -- 0 (FALSE)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;SELECT 1 &lt;=&gt; 1;         -- 1 (TRUE)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;SELECT 1 &lt;=&gt; 2;         -- 0 (FALSE)&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes it safe to compare nullable columns directly:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;-- Correctly includes rows where both columns are NULL
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;SELECT * FROM orders WHERE shipping_address &lt;=&gt; billing_address;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;A real-world example&lt;/h1&gt;
&lt;p&gt;Consider a polymorphic &lt;code&gt;subscriptions&lt;/code&gt; join table that maps users to subscribable entities (posts, documents, threads, etc.). The goal: return all subscribers &lt;em&gt;excluding&lt;/em&gt; the item&apos;s author, even when &lt;code&gt;author_id&lt;/code&gt; might be &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The naive approach breaks silently:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;WHERE subscriptions.user_id != posts.author_id&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When &lt;code&gt;author_id&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;, this evaluates to &lt;code&gt;NULL&lt;/code&gt;, so the row is dropped — meaning a user who subscribes to a post with no author would incorrectly disappear from the result.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The null-safe fix:&lt;/strong&gt;&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;NOT (subscriptions.user_id &lt;=&gt; (
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;    SELECT author_id FROM posts WHERE id = subscriptions.item_id
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;))&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This reads as: &lt;em&gt;&quot;keep this row unless the subscriber&apos;s user_id exactly matches the author_id, treating NULL as a concrete equal value.&quot;&lt;/em&gt; When &lt;code&gt;author_id&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; and &lt;code&gt;user_id&lt;/code&gt; is not, the &lt;code&gt;&lt;=&gt;&lt;/code&gt; returns &lt;code&gt;FALSE&lt;/code&gt;, so &lt;code&gt;NOT FALSE&lt;/code&gt; is &lt;code&gt;TRUE&lt;/code&gt; — the row is kept. Correct behaviour in all cases.&lt;/p&gt;
&lt;h1&gt;&lt;code&gt;&lt;=&gt;&lt;/code&gt; vs. the alternatives&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Handles NULL?&lt;/th&gt;
&lt;th&gt;Readable?&lt;/th&gt;
&lt;th&gt;Standard SQL?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;col = val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;col IS NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (literal only)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COALESCE(col, &apos;&apos;) = COALESCE(val, &apos;&apos;)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (with sentinel)&lt;/td&gt;
&lt;td&gt;Passable&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(col = val OR (col IS NULL AND val IS NULL))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Verbose&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;col &lt;=&gt; val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (MySQL/MariaDB)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;COALESCE&lt;/code&gt; sentinel approach is fragile — you need to pick a value that can never appear in real data. The verbose &lt;code&gt;OR (IS NULL AND IS NULL)&lt;/code&gt; pattern works but is noisy. &lt;code&gt;&lt;=&gt;&lt;/code&gt; wins on brevity and correctness, at the cost of portability.&lt;/p&gt;
&lt;h1&gt;When to use it&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;&lt;=&gt;&lt;/code&gt; is a good fit when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Comparing two nullable columns&lt;/strong&gt; directly in a &lt;code&gt;WHERE&lt;/code&gt; or &lt;code&gt;JOIN&lt;/code&gt; condition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Negating equality on nullable data&lt;/strong&gt; (&lt;code&gt;NOT (a &lt;=&gt; b)&lt;/code&gt; is cleaner than the alternative).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Upsert / deduplication queries&lt;/strong&gt; where you need exact matching including null identity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generated columns or audit logic&lt;/strong&gt; where you want to detect whether a value actually changed, including transitions to/from &lt;code&gt;NULL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;Caveats&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;MySQL and MariaDB only.&lt;/strong&gt; The &lt;code&gt;&lt;=&gt;&lt;/code&gt; operator is not part of the SQL standard and is not available in PostgreSQL, SQLite, or SQL Server. If your codebase runs tests against SQLite (a common Laravel setup), any &lt;code&gt;&lt;=&gt;&lt;/code&gt; in a raw query will fail there.&lt;/p&gt;
&lt;p&gt;PostgreSQL&apos;s equivalent is &lt;code&gt;IS NOT DISTINCT FROM&lt;/code&gt; / &lt;code&gt;IS DISTINCT FROM&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-sql&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;-- PostgreSQL equivalent of &lt;=&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;col IS NOT DISTINCT FROM val
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;-- PostgreSQL equivalent of NOT (col &lt;=&gt; val)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;col IS DISTINCT FROM val&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If cross-database portability matters, abstract the comparison behind a query scope or use the verbose but portable &lt;code&gt;OR (IS NULL AND IS NULL)&lt;/code&gt; form.&lt;/p&gt;
&lt;h1&gt;Summary&lt;/h1&gt;
&lt;p&gt;The null-safe equality operator &lt;code&gt;&lt;=&gt;&lt;/code&gt; is one of those small MySQL features that, once you know it exists, saves you from a whole class of subtle bugs. It&apos;s most valuable when you need to compare nullable columns directly — particularly in negated conditions where the standard &lt;code&gt;!=&lt;/code&gt; would silently swallow &lt;code&gt;NULL&lt;/code&gt; rows. Just keep portability in mind: it&apos;s a MySQL/MariaDB extension, so make sure your test database matches your production database before reaching for it.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/mysql&quot;&gt;#mysql&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/sql&quot;&gt;#sql&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-06-18T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/the-mysql-null-safe-equality-operator</id>
    <title>🐥 The MySQL null-safe equality operator: &lt;=&gt;</title>
    <updated>2026-06-18T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/testing-that-laravel-events-fire-after-a-transaction-commits" rel="alternate"/>
    <content type="html">&lt;p&gt;A common source of bugs in Laravel applications is dispatching events &lt;em&gt;inside&lt;/em&gt; a database transaction. Listeners often kick off their own queries or even their own transactions — and if the outer transaction hasn&apos;t committed yet, you can end up with deadlocks, stale reads, or listeners that act on data that gets rolled back.&lt;/p&gt;
&lt;p&gt;The fix is straightforward: dispatch events &lt;em&gt;after&lt;/em&gt; the transaction commits. But how do you write a test that actually enforces this? Here&apos;s a reusable pattern.&lt;/p&gt;
&lt;h1&gt;The problem&lt;/h1&gt;
&lt;p&gt;Consider an &lt;code&gt;OrderAction&lt;/code&gt; that saves an order inside a transaction and then fires an &lt;code&gt;OrderWasPlaced&lt;/code&gt; event:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;final class PlaceOrderAction
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    public function __invoke(Cart $cart): Order
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        DB::transaction(function () use ($cart) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;            $order = Order::create([...]);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;            $order-&gt;lines()-&gt;createMany($cart-&gt;lines());
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;            
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;            // ❌ Event fired inside the transaction — listeners run
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;            //    while the order row is still locked.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;            Event::dispatch(new OrderWasPlaced($order));
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;        });
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;    }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any listener that tries to read the same rows will block (or deadlock) because the transaction still holds row locks.&lt;/p&gt;
&lt;p&gt;The correct version moves the dispatch outside:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;final class PlaceOrderAction
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    public function __invoke(Cart $cart): Order
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        $order = null;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;        DB::transaction(function () use ($cart, &amp;$order) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;            $order = Order::create([...]);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;            $order-&gt;lines()-&gt;createMany($cart-&gt;lines());
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;        });
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;        // ✅ Transaction has committed — listeners can safely read/write.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;        Event::dispatch(new OrderWasPlaced($order));
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;        return $order;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;    }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;The test pattern&lt;/h1&gt;
&lt;p&gt;The key idea: register a real event listener &lt;em&gt;before&lt;/em&gt; the action runs, and capture &lt;code&gt;DB::transactionLevel()&lt;/code&gt; at the moment the event fires. If the event fires inside the transaction the level will be elevated; if it fires after the commit it will match the baseline.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;use App\Actions\PlaceOrderAction;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;use App\Events\OrderWasPlaced;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;use Illuminate\Support\Facades\Bus;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;use Illuminate\Support\Facades\DB;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;use Illuminate\Support\Facades\Event;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;use PHPUnit\Framework\Attributes\Test;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;use Tests\TestCase;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;final class PlaceOrderActionTest extends TestCase
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    #[Test]
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    public function it_dispatches_order_was_placed_after_the_transaction_commits(): void
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;    {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;        // Fake jobs/queues so side-effect listeners don&amp;#39;t cascade.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;        Bus::fake();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;        // Capture the DB nesting depth before the action runs.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;        // LazilyRefreshDatabase / DatabaseTransactions wraps every test
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;        // in its own transaction, so the baseline is typically 1, not 0.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;        $baselineLevel = DB::transactionLevel();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;        $levelAtDispatch = null;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;        // Register a real listener — do NOT call Event::fake(), otherwise
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;26&quot;&gt;        // the dispatcher is replaced with a mock and no listeners run.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;27&quot;&gt;        Event::listen(OrderWasPlaced::class, function () use (&amp;$levelAtDispatch) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;28&quot;&gt;            $levelAtDispatch = DB::transactionLevel();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;29&quot;&gt;        });
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;30&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;31&quot;&gt;        $cart = Cart::factory()-&gt;withLines(3)-&gt;create();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;32&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;33&quot;&gt;        app(PlaceOrderAction::class)($cart);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;34&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;35&quot;&gt;        $this-&gt;assertEquals(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;36&quot;&gt;            $baselineLevel,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;37&quot;&gt;            $levelAtDispatch,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;38&quot;&gt;            &amp;#39;OrderWasPlaced must be dispatched after the transaction commits, not inside it.&amp;#39;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;39&quot;&gt;        );
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;40&quot;&gt;    }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;41&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Why compare against &lt;code&gt;$baselineLevel&lt;/code&gt; instead of &lt;code&gt;0&lt;/code&gt;?&lt;/h2&gt;
&lt;p&gt;Most Laravel test suites use &lt;code&gt;LazilyRefreshDatabase&lt;/code&gt; or &lt;code&gt;DatabaseTransactions&lt;/code&gt;, which wrap every test in an outer transaction for easy rollback. That means &lt;code&gt;DB::transactionLevel()&lt;/code&gt; starts at &lt;code&gt;1&lt;/code&gt; when the test body begins — not &lt;code&gt;0&lt;/code&gt;. Comparing against the snapshot taken &lt;em&gt;before&lt;/em&gt; the action runs is always correct, regardless of your test database strategy.&lt;/p&gt;
&lt;h1&gt;Handling listener cascades&lt;/h1&gt;
&lt;p&gt;Sometimes the event you want to observe triggers further actions that write to the database, causing failures when those writes reference data that doesn&apos;t exist in the current test context. Two strategies:&lt;/p&gt;
&lt;h2&gt;1. Fake only the cascading action&lt;/h2&gt;
&lt;p&gt;If a listener dispatches a secondary action (e.g. &lt;code&gt;StartFulfillmentAction&lt;/code&gt;), fake just that class so the chain stops there while leaving the event dispatcher intact:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;Action::fake(StartFulfillmentAction::class);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The event still fires and your transaction-level listener still runs.&lt;/p&gt;
&lt;h2&gt;2. Fake only the jobs&lt;/h2&gt;
&lt;p&gt;If listeners queue jobs (Horizon, etc.), &lt;code&gt;Bus::fake()&lt;/code&gt; is usually enough to prevent the cascade without touching the event system at all.&lt;/p&gt;
&lt;h1&gt;Checking multiple events&lt;/h1&gt;
&lt;p&gt;To assert that &lt;em&gt;all&lt;/em&gt; the events fired by an action respect the post-commit invariant, collect them all in a single map:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;$levels = [];
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;foreach ([OrderWasPlaced::class, InventoryReserved::class, InvoiceQueued::class] as $eventClass) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    Event::listen($eventClass, function () use ($eventClass, &amp;$levels) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;        $levels[$eventClass] = DB::transactionLevel();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;    });
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;app(PlaceOrderAction::class)($cart);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;foreach (array_keys($levels) as $eventClass) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;    $this-&gt;assertEquals(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;        $baselineLevel,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;        $levels[$eventClass],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;        &quot;{$eventClass} must be dispatched outside the transaction.&quot;,
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;    );
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;}
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;// Also assert every expected event actually fired.
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;$this-&gt;assertSame(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;    [OrderWasPlaced::class, InventoryReserved::class, InvoiceQueued::class],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;    array_keys($levels),
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;);&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;Quick reference&lt;/h1&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single event&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Event::listen()&lt;/code&gt; + &lt;code&gt;DB::transactionLevel()&lt;/code&gt; snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple events&lt;/td&gt;
&lt;td&gt;Loop over event classes, collect levels into a map&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Listener cascade breaks the test&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Action::fake(CascadingAction::class)&lt;/code&gt; or &lt;code&gt;Bus::fake()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using &lt;code&gt;Event::fake()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ Replaces the dispatcher — listeners never run, pattern breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction depth varies by test setup&lt;/td&gt;
&lt;td&gt;Always snapshot &lt;code&gt;$baselineLevel&lt;/code&gt; before the action, never hardcode &lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The pattern is lightweight — no mocking frameworks, no custom test doubles, just a listener closure and a single assertion. Once you&apos;ve added it to one action test, it&apos;s easy to copy across the codebase anywhere you need to enforce the same invariant.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/database&quot;&gt;#database&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/testing&quot;&gt;#testing&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-06-16T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/testing-that-laravel-events-fire-after-a-transaction-commits</id>
    <title>🐥 Testing that Laravel events fire after a transaction commits</title>
    <updated>2026-06-16T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/speeding-up-s3-uploads-in-github-actions-with-bash-parallelism" rel="alternate"/>
    <content type="html">&lt;p&gt;When you&apos;re uploading multiple directories to S3 (or an S3-compatible CDN like DigitalOcean Spaces) in a CI pipeline, the naive approach runs each upload sequentially. If you have three directories and each takes 30 seconds, you&apos;re waiting 90 seconds. They&apos;re completely independent — there&apos;s no reason not to run them at the same time.&lt;/p&gt;
&lt;p&gt;Here&apos;s how to parallelize them with nothing but bash.&lt;/p&gt;
&lt;h1&gt;The problem&lt;/h1&gt;
&lt;p&gt;A typical multi-directory upload step looks like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;- name: Upload to CDN
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  run: |
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    s3cmd put public/build s3://my-bucket/assets/ --recursive --acl-public
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    s3cmd put public/img   s3://my-bucket/assets/ --recursive --acl-public
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    s3cmd put public/js    s3://my-bucket/assets/ --recursive --acl-public&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;s3cmd put&lt;/code&gt; blocks until it&apos;s done before the next one starts. Wall-clock time = sum of all three.&lt;/p&gt;
&lt;h1&gt;The fix&lt;/h1&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-yaml&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;- name: Upload to CDN
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;  run: |
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;    pids=()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;    s3cmd put public/build s3://my-bucket/assets/ --recursive --acl-public &amp; pids+=($!)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    s3cmd put public/img   s3://my-bucket/assets/ --recursive --acl-public &amp; pids+=($!)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;    s3cmd put public/js    s3://my-bucket/assets/ --recursive --acl-public &amp; pids+=($!)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    for pid in &quot;${pids[@]}&quot;; do wait &quot;$pid&quot; || exit 1; done&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wall-clock time = duration of the slowest upload.&lt;/p&gt;
&lt;h1&gt;How it works&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp; pids+=($!)&lt;/code&gt;&lt;/strong&gt; — The &lt;code&gt;&amp;&lt;/code&gt; runs the command in the background. &lt;code&gt;$!&lt;/code&gt; is bash&apos;s special variable for the PID of the last backgrounded process, and we immediately append it to the &lt;code&gt;pids&lt;/code&gt; array before starting the next job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;for pid in &quot;${pids[@]}&quot;; do wait &quot;$pid&quot; || exit 1; done&lt;/code&gt;&lt;/strong&gt; — We wait for each background job by PID and fail the step immediately if any one of them exits with a non-zero code. This is important: a plain &lt;code&gt;wait&lt;/code&gt; without arguments returns the exit code of the &lt;em&gt;last&lt;/em&gt; process it waited for, which means a failure in the first or second upload could go undetected.&lt;/p&gt;
&lt;h1&gt;Why not just &lt;code&gt;wait&lt;/code&gt;?&lt;/h1&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;# Dangerous — only checks the exit code of the last job
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;s3cmd put public/build ... &amp;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;s3cmd put public/img   ... &amp;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;s3cmd put public/js    ... &amp;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;wait&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;public/build&lt;/code&gt; fails but &lt;code&gt;public/js&lt;/code&gt; succeeds, this exits 0 and your CI run goes green with a broken CDN.&lt;/p&gt;
&lt;p&gt;Waiting by PID and checking each one individually gives you the same safety guarantee as running sequentially, at the speed of the fastest possible parallel execution.&lt;/p&gt;
&lt;h1&gt;The general pattern&lt;/h1&gt;
&lt;p&gt;This technique works for any set of independent shell commands you want to parallelize:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;pids=()
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;some-command arg1 &amp; pids+=($!)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;some-command arg2 &amp; pids+=($!)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;some-command arg3 &amp; pids+=($!)
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;for pid in &quot;${pids[@]}&quot;; do wait &quot;$pid&quot; || exit 1; done&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No extra tooling, no GNU Parallel, no xargs — just bash.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/tools&quot;&gt;#tools&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/devops&quot;&gt;#devops&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/github&quot;&gt;#github&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-06-14T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/speeding-up-s3-uploads-in-github-actions-with-bash-parallelism</id>
    <title>🐥 Speeding up S3 uploads in GitHub Actions with Bash parallelism</title>
    <updated>2026-06-14T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/debugging-phpunit-notices-in-laravel-parallel-tests" rel="alternate"/>
    <content type="html">&lt;p&gt;Running Laravel&apos;s test suite in parallel speeds things up considerably, but it also makes it easy to miss PHPUnit notices. The parallel worker output gets interleaved and buffered, and notices about deprecated API usage or risky tests tend to scroll past unnoticed — or disappear entirely. This post shows the command I use to surface them reliably.&lt;/p&gt;
&lt;h1&gt;The command&lt;/h1&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;LARAVEL_PARALLEL_TESTING=1 \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES=1 \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;vendor/brianium/paratest/bin/paratest \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  --colors=always \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  --configuration=/path/to/phpunit.xml \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  --runner=\\Illuminate\\Testing\\ParallelRunner \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  --display-phpunit-notices \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;  --fail-on-all-issues \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;  tests/Unit&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;What each part does&lt;/h1&gt;
&lt;h2&gt;Environment variables&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;LARAVEL_PARALLEL_TESTING=1&lt;/code&gt; activates Laravel&apos;s parallel testing support. It causes the framework to spin up separate database connections per worker (suffixed &lt;code&gt;_1&lt;/code&gt;, &lt;code&gt;_2&lt;/code&gt;, etc.) and seed each one independently.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES=1&lt;/code&gt; forces those databases to be dropped and recreated from scratch on every run. Without it, a previous run&apos;s leftover state can cause tests to pass or fail for the wrong reasons — particularly relevant when you change a migration between runs.&lt;/p&gt;
&lt;h2&gt;Invoking paratest directly&lt;/h2&gt;
&lt;p&gt;Laravel&apos;s &lt;code&gt;php artisan test --parallel&lt;/code&gt; is a thin wrapper around &lt;code&gt;brianium/paratest&lt;/code&gt;. Calling the binary directly gives you access to flags that the Artisan wrapper doesn&apos;t expose, particularly the notice-related ones below.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--runner=\\Illuminate\\Testing\\ParallelRunner&lt;/code&gt; tells paratest to use Laravel&apos;s own runner class, which handles the database token injection and other framework-specific setup that the default runner skips.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--configuration&lt;/code&gt; takes an absolute path to your &lt;code&gt;phpunit.xml&lt;/code&gt;. When you invoke paratest from outside the project root — for example, from a CI script or a Makefile — relative paths silently resolve to the wrong location. Using an absolute path avoids that class of silent misconfiguration.&lt;/p&gt;
&lt;h2&gt;Surfacing notices&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;--display-phpunit-notices&lt;/code&gt; is the key flag. PHPUnit emits notices for things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;calls to deprecated assertion methods (&lt;code&gt;assertContains&lt;/code&gt; on a string instead of &lt;code&gt;assertStringContainsString&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;tests marked &lt;code&gt;@covers&lt;/code&gt; that cover no code&lt;/li&gt;
&lt;li&gt;tests with no assertions when &lt;code&gt;beStrictAboutTestsThatDoNotTestAnything&lt;/code&gt; is enabled&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In a parallel run these notices are buffered per worker and often never reach the terminal. This flag ensures they are printed to output regardless.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--fail-on-all-issues&lt;/code&gt; treats any notice, warning, or deprecation as a test suite failure. This is what makes the command useful for CI: the exit code becomes non-zero the moment any worker emits a notice, so the pipeline fails and forces you to deal with it rather than letting it accumulate.&lt;/p&gt;
&lt;h2&gt;Scoping to &lt;code&gt;tests/Unit&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Passing &lt;code&gt;tests/Unit&lt;/code&gt; as the path restricts the run to unit tests, which tends to surface notices faster than a full suite run because unit tests don&apos;t need a running server or real database queries. Once you&apos;ve cleared the unit test notices, you can repeat with &lt;code&gt;tests/Feature&lt;/code&gt; or omit the path entirely.&lt;/p&gt;
&lt;h1&gt;Reading the output&lt;/h1&gt;
&lt;p&gt;When a notice fires, paratest prints it alongside the failing worker output. The format looks like:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-plaintext&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;NOTICE  tests/Unit/Services/OrderServiceTest.php:42
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;Method Illuminate\Testing\Assert::assertContains() is deprecated. Use assertStringContainsString() instead.&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The file path and line number point directly to the test method that triggered it. If you see the same notice repeating across many tests, the issue is usually in a shared base class or a trait — check the stack trace for the actual call site rather than fixing every test individually.&lt;/p&gt;
&lt;h1&gt;Making it a habit&lt;/h1&gt;
&lt;p&gt;The goal is to run with &lt;code&gt;--fail-on-all-issues&lt;/code&gt; in CI from the start, before notices accumulate. If you&apos;re adding this to an existing codebase, it&apos;s usually easier to tackle notices test-file by test-file: run against a single file first, fix what you find, then broaden the path incrementally.&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;# Start with one file
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;LARAVEL_PARALLEL_TESTING=1 \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;vendor/brianium/paratest/bin/paratest \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;  --runner=\\Illuminate\\Testing\\ParallelRunner \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;  --display-phpunit-notices \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;  --fail-on-all-issues \
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;  tests/Unit/Services/OrderServiceTest.php&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the file is clean, commit and move on to the next. The &lt;code&gt;--fail-on-all-issues&lt;/code&gt; flag acts as a ratchet: once a file is notice-free, CI will catch any regression immediately.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/laravel&quot;&gt;#laravel&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/php&quot;&gt;#php&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/testing&quot;&gt;#testing&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-06-12T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/debugging-phpunit-notices-in-laravel-parallel-tests</id>
    <title>🐥 Debugging PHPUnit notices in Laravel parallel tests</title>
    <updated>2026-06-12T17:00:00Z</updated>
  </entry>
  <entry>
    <author>
      <name>Pieter Claerhout</name>
      <email>pieter@yellowduck.be</email>
    </author>
    <link href="https://www.yellowduck.be/posts/from-n-1-to-1-extracting-paged-pdf-text-with-a-single-pdftotext-call" rel="alternate"/>
    <content type="html">&lt;p&gt;When building full-text search for uploaded documents, we needed to extract text page-by-page from PDFs so we could index each page as a separate chunk. The naive approach worked but was painfully slow. Here&apos;s how a single Unix insight cut it down to one process spawn.&lt;/p&gt;
&lt;h1&gt;The problem: N+1 process spawns&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;pdftotext&lt;/code&gt; is the standard Unix utility for extracting text from PDFs. It supports a &lt;code&gt;-f&lt;/code&gt; (first page) and &lt;code&gt;-l&lt;/code&gt; (last page) flag, so extracting a single page looks like this:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;pdftotext -f 3 -l 3 document.pdf -&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The natural implementation for paged extraction is to call this in a loop:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;public function getPagedTextFromFile(string $path): Collection
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    $pagedText = collect();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    $pageCount = $this-&gt;pdfInfoService-&gt;getPageCountFromFile($path);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;    for ($i = 1; $i &lt;= $pageCount; $i++) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;        $pagedText-&gt;put($i, $this-&gt;getRawTextFromFile($path, $i));
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;    }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;    return $pagedText;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is clean and obvious. It is also, for any document with real content, expensive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1 &lt;code&gt;pdfinfo&lt;/code&gt; call to get the page count&lt;/li&gt;
&lt;li&gt;N &lt;code&gt;pdftotext&lt;/code&gt; calls, one per page&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each call is a separate process spawn: the OS forks, loads the binary, opens the PDF, seeks to the requested page, extracts text, and exits. For a 50-page contract, that&apos;s 51 process spawns. On a server handling concurrent uploads, those 51 spawns happen in sequence, blocking the queue worker the entire time.&lt;/p&gt;
&lt;h1&gt;The insight: pdftotext already separates pages&lt;/h1&gt;
&lt;p&gt;Run &lt;code&gt;pdftotext&lt;/code&gt; without page flags and pipe the output to a hex viewer:&lt;/p&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-bash&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;pdftotext document.pdf - | cat -A | grep -P &amp;#39;\f&amp;#39;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll see form feed characters (&lt;code&gt;\f&lt;/code&gt;, &lt;code&gt;\x0C&lt;/code&gt;, ASCII 12) separating each page. This is standard — &lt;code&gt;pdftotext&lt;/code&gt; has always done this. It&apos;s even documented in the man page, buried under the output format description.&lt;/p&gt;
&lt;p&gt;That means the full multi-page text is already structured. We don&apos;t need N calls. We need one call and a string split.&lt;/p&gt;
&lt;h1&gt;The solution&lt;/h1&gt;
&lt;pre class=&quot;lumis&quot;&gt;&lt;code class=&quot;language-php&quot; translate=&quot;no&quot; tabindex=&quot;0&quot;&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;1&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;2&quot;&gt;&lt;?php
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;3&quot;&gt;public function getPagedTextFromFile(string $path): Collection
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;4&quot;&gt;{
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;5&quot;&gt;    $pagedText = collect();
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;6&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;7&quot;&gt;    try {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;8&quot;&gt;        if ($this-&gt;mimeTypeForPath($path) !== &amp;#39;application/pdf&amp;#39;) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;9&quot;&gt;            return $pagedText;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;10&quot;&gt;        }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;11&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;12&quot;&gt;        $result = $this-&gt;runExternalProcess(
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;13&quot;&gt;            [config(&amp;#39;pdftools.pdftotext&amp;#39;), $path, &amp;#39;-&amp;#39;],
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;14&quot;&gt;            180
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;15&quot;&gt;        );
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;16&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;17&quot;&gt;        if ($result-&gt;getStdErr() !== &amp;#39;&amp;#39;) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;18&quot;&gt;            throw new Exception($result-&gt;getStdErr());
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;19&quot;&gt;        }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;20&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;21&quot;&gt;        // pdftotext separates pages with \f; rtrim strips any optional trailing \f
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;22&quot;&gt;        $pages = explode(&quot;\f&quot;, rtrim($result-&gt;getStdOut(), &quot;\f&quot;));
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;23&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;24&quot;&gt;        foreach ($pages as $i =&gt; $pageText) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;25&quot;&gt;            $pageText = trim($pageText, &quot; \t\n\r\0\x0B&quot;);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;26&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;27&quot;&gt;            if ($pageText !== &amp;#39;&amp;#39;) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;28&quot;&gt;                $lines = $this-&gt;splitInLines($pageText);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;29&quot;&gt;                if (!empty($lines) &amp;&amp; preg_match(self::DOCUSIGN_HEADER_PATTERN, $lines[0])) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;30&quot;&gt;                    array_shift($lines);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;31&quot;&gt;                    $pageText = trim(implode(&quot;\n&quot;, $lines), &quot; \t\n\r\0\x0B&quot;);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;32&quot;&gt;                }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;33&quot;&gt;            }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;34&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;35&quot;&gt;            $pagedText-&gt;put($i + 1, $pageText !== &amp;#39;&amp;#39; ? $pageText : null);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;36&quot;&gt;        }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;37&quot;&gt;    } catch (Exception $e) {
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;38&quot;&gt;        Log::error(&quot;pdftotext | {$e-&gt;getMessage()}&quot;);
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;39&quot;&gt;    }
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;40&quot;&gt;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;41&quot;&gt;    return $pagedText;
&lt;/div&gt;&lt;div class=&quot;l-line&quot; data-line=&quot;42&quot;&gt;}&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; 51 process spawns for a 50-page PDF.&lt;br /&gt;
&lt;strong&gt;After:&lt;/strong&gt; 1 process spawn, regardless of page count.&lt;/p&gt;
&lt;p&gt;For a 100-page document the old approach invoked &lt;code&gt;pdftotext&lt;/code&gt; 100 times, each time reloading and re-parsing the entire PDF file to seek to one page. The new approach loads it once and returns everything.&lt;/p&gt;
&lt;h1&gt;Edge cases worth knowing&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;Trailing form feed.&lt;/strong&gt; Some versions of &lt;code&gt;pdftotext&lt;/code&gt; append a &lt;code&gt;\f&lt;/code&gt; after the last page. Splitting &lt;code&gt;&quot;page1\fpage2\f&quot;&lt;/code&gt; naively gives &lt;code&gt;[&quot;page1&quot;, &quot;page2&quot;, &quot;&quot;]&lt;/code&gt; — an extra empty element. The &lt;code&gt;rtrim($output, &quot;\f&quot;)&lt;/code&gt; before splitting removes it cleanly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Blank pages.&lt;/strong&gt; A blank page produces an empty string after trimming. Storing &lt;code&gt;null&lt;/code&gt; for it preserves correct page numbering for subsequent pages (page 5 stays page 5, even if pages 3 and 4 are blank). The downstream indexing code skips nulls, so blank pages don&apos;t pollute the search index.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Page-level headers.&lt;/strong&gt; We strip DocuSign envelope headers (&lt;code&gt;DocuSign Envelope ID: XXXXXXXX-...&lt;/code&gt;) from the beginning of any page that has one. Since the single-call output is split by page before this check, the per-page stripping logic is identical to the per-call approach — just applied after the split instead of inside each &lt;code&gt;getRawTextFromFile&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Non-PDF files.&lt;/strong&gt; The MIME type check at the top of the method returns early for anything that isn&apos;t &lt;code&gt;application/pdf&lt;/code&gt;. This replaces the earlier dependency on &lt;code&gt;pdfinfo&lt;/code&gt; to get the page count — for non-PDFs that check would have returned 0 and short-circuited the loop, but the MIME check is simpler and removes the &lt;code&gt;pdfinfo&lt;/code&gt; dependency from this path entirely.&lt;/p&gt;
&lt;h1&gt;The broader pattern&lt;/h1&gt;
&lt;p&gt;This is an instance of a general optimisation: if a tool is designed to process a whole file, don&apos;t call it once per chunk. The tool already knows how to walk the file efficiently; let it.&lt;/p&gt;
&lt;p&gt;The same principle applies to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ffprobe&lt;/code&gt; for video metadata — one call for all streams, not one call per stream&lt;/li&gt;
&lt;li&gt;&lt;code&gt;exiftool&lt;/code&gt; — batch mode processes a directory in one pass rather than per-file invocations&lt;/li&gt;
&lt;li&gt;Database queries — &lt;code&gt;SELECT&lt;/code&gt; with &lt;code&gt;IN (...)&lt;/code&gt; instead of N individual selects&lt;/li&gt;
&lt;li&gt;Meilisearch document uploads — one &lt;code&gt;addDocuments&lt;/code&gt; call with a batch, not one call per document&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In each case the per-item call pattern feels natural and is easy to reason about. But the overhead of repeatedly invoking a tool that was designed for whole-file processing accumulates fast once documents are large or queues are busy.&lt;/p&gt;
&lt;p&gt;The fix, when it exists, is usually as simple as this one: read the man page, find the output format, split a string.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.yellowduck.be/tags/pdf&quot;&gt;#pdf&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/development&quot;&gt;#development&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/php&quot;&gt;#php&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/tools&quot;&gt;#tools&lt;/a&gt; &lt;a href=&quot;https://www.yellowduck.be/tags/best-practice&quot;&gt;#best-practice&lt;/a&gt;&lt;/p&gt;</content>
    <published>2026-06-10T17:00:00Z</published>
    <id>https://www.yellowduck.be/posts/from-n-1-to-1-extracting-paged-pdf-text-with-a-single-pdftotext-call</id>
    <title>🐥 From N+1 to 1: Extracting paged PDF text with a single pdftotext call</title>
    <updated>2026-06-10T17:00:00Z</updated>
  </entry>
</feed>