<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ivan Mykhavko</title>
    <description>The latest articles on DEV Community by Ivan Mykhavko (@tegos).</description>
    <link>https://dev.to/tegos</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2577823%2F2a1a07a7-4bbf-4df1-9239-7997587abb65.jpg</url>
      <title>DEV Community: Ivan Mykhavko</title>
      <link>https://dev.to/tegos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tegos"/>
    <language>en</language>
    <item>
      <title>app() Is Not Dependency Injection: 18 Calls Down to Zero</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Sun, 25 Oct 2026 22:10:07 +0000</pubDate>
      <link>https://dev.to/tegos/app-is-not-dependency-injection-18-calls-down-to-zero-k90</link>
      <guid>https://dev.to/tegos/app-is-not-dependency-injection-18-calls-down-to-zero-k90</guid>
      <description>&lt;p&gt;I keep hearing "we use DI everywhere" from Laravel devs whose code is full of &lt;code&gt;app(SomeService::class)&lt;/code&gt;. The container builds the object, so it feels like DI. It's not. It's Service Locator, and I've said so in code reviews for years.&lt;/p&gt;

&lt;p&gt;And still, when I went through the git history of the project I work on, I counted 18 of these calls at the peak. Knowing it's an anti-pattern and not writing it are two different things. This is how that gap closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But We Use DI Everywhere"
&lt;/h2&gt;

&lt;p&gt;Here's what I mean. Both of these get their dependency from the container. The first one is from our codebase in 2024:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Models/User.php, 2024: Service Locator, the class asks for it&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getCart&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Cart&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @var CartService $cartService */&lt;/span&gt;
    &lt;span class="nv"&gt;$cartService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CartService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cartService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCartEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is how a cart action looks on our main branch today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Actions/Cart/CartIndexAction.php, today: dependency injection, the class receives it&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CartIndexAction&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Actionable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;CartItemDtoFactory&lt;/span&gt; &lt;span class="nv"&gt;$cartItemDtoFactory&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CartItemParamDTO&lt;/span&gt; &lt;span class="nv"&gt;$cartItemParamDTO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$managerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Collection&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$cartItemDTOs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cartItemDtoFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchForDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$managerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cartItemParamDTO&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;itemUuids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cartItemDTOs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two quick definitions, so we argue about the same thing. With dependency injection, a class gets its dependencies from outside, through the constructor or a method parameter, and doesn't care who built them. With Service Locator, the class asks a shared registry for them itself, in the middle of its own code. In Laravel, that registry is the container, and &lt;code&gt;app()&lt;/code&gt; is the call.&lt;/p&gt;

&lt;p&gt;The container creates the dependency in both cases. What changes is &lt;strong&gt;who knows about whom&lt;/strong&gt;. In the first one, &lt;code&gt;User&lt;/code&gt; knows about the container and hides its dependency inside a method body. In the second one, the class has no idea where &lt;code&gt;CartItemDtoFactory&lt;/code&gt; comes from, and the constructor tells you everything it needs.&lt;/p&gt;

&lt;p&gt;That's the whole cost of Service Locator. The class signature lies about its dependencies, a missing binding fails at runtime instead of at construction, and a constructor that grows too long is a useful warning that &lt;code&gt;app()&lt;/code&gt; simply hides.&lt;/p&gt;

&lt;p&gt;The Laravel docs lean the same way. They show &lt;code&gt;app()&lt;/code&gt; and &lt;code&gt;App::make()&lt;/code&gt; for code that has no &lt;code&gt;$app&lt;/code&gt; variable at hand, and then, about constructor type-hints:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In practice, this is how most of your objects should be resolved by the container.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One scope note before the numbers. Facades and helpers like &lt;code&gt;config()&lt;/code&gt; or &lt;code&gt;Cache::&lt;/code&gt; pull from the container too, and some people call them Service Locator as well. I didn't count them. That's a different debate. This post is only about explicit &lt;code&gt;app(Foo::class)&lt;/code&gt;-style resolution of the project's own classes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Four Years of Git History Show
&lt;/h2&gt;

&lt;p&gt;I counted calls to &lt;code&gt;app(Foo::class)&lt;/code&gt;, &lt;code&gt;app()-&amp;gt;make()&lt;/code&gt;, &lt;code&gt;App::make()&lt;/code&gt; and &lt;code&gt;resolve(Foo::class)&lt;/code&gt; in &lt;code&gt;app/&lt;/code&gt; on the main branch at a few points in time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Service Locator calls&lt;/th&gt;
&lt;th&gt;PHP files in &lt;code&gt;app/&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2022-07&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023-01&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;235&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023-07&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;371&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024-01&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;428&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024-07&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;548&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-01&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;856&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-07&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;965&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-01&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1052&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-05&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1058&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1166&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From the 18-call peak down to 0, while &lt;code&gt;app/&lt;/code&gt; grew from 371 to over 1,000 files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All four spellings lived in the same codebase.&lt;/strong&gt; Nobody ever decided "we use a Service Locator here". It just happened.&lt;/p&gt;

&lt;p&gt;The worst method I found, in a search service from 2024, resolved four services in a row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Services/Search/SearchResultService.php, 2024&lt;/span&gt;
&lt;span class="cd"&gt;/** @var PriceService $priceService */&lt;/span&gt;
&lt;span class="nv"&gt;$priceService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PriceService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cd"&gt;/** @var CurrencyService $currencyService */&lt;/span&gt;
&lt;span class="nv"&gt;$currencyService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CurrencyService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="cd"&gt;/** @var CartService $cartService */&lt;/span&gt;
&lt;span class="nv"&gt;$cartService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CartService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cd"&gt;/** @var WarehouseService $warehouseService */&lt;/span&gt;
&lt;span class="nv"&gt;$warehouseService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WarehouseService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four dependencies, and the constructor mentioned none of them.&lt;/p&gt;

&lt;p&gt;A good part of the drop was not refactoring at all. Of the 18, 10 went away only because the whole file was deleted. &lt;code&gt;User::getCart()&lt;/code&gt; from the example above was dead too. The cart actions had switched to an injected cart repository long before, and only other dead code still called the model method. It went out with a "Cleanup" commit, together with a few similar calls. The last one needed a real refactoring. Let's be honest: most of these calls lived in old code, and old code gets deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Last One: A Model That Built Its Own Primary Key
&lt;/h2&gt;

&lt;p&gt;The last call survived until April 2026. It lived in the &lt;code&gt;Order&lt;/code&gt; model, which generated its own 8-digit id with a year prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Models/Order.php, until April 2026&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;newUniqueId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="cd"&gt;/** @var PrimaryKeyIntegerGenerator $primaryKeyIntegerGenerator */&lt;/span&gt;
    &lt;span class="nv"&gt;$primaryKeyIntegerGenerator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PrimaryKeyIntegerGenerator&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$primaryKeyIntegerGenerator&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setLockSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'limits.order.min_processing_time_lock'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setBuilderAggregate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;strval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$primaryKeyIntegerGenerator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eloquent creates models with &lt;code&gt;new&lt;/code&gt;, not through the container, so you can't inject anything into a model constructor. And &lt;code&gt;newUniqueId()&lt;/code&gt; is a hook Eloquent calls itself, with no arguments. Inside a hook like that, you can only ask for a service: &lt;code&gt;app()&lt;/code&gt; or a facade. So &lt;strong&gt;if a model needs &lt;code&gt;app()&lt;/code&gt;, the logic is probably in the wrong place&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;To be fair, &lt;code&gt;app()&lt;/code&gt; itself wasn't what blocked testing. In Laravel you can swap a binding with &lt;code&gt;$this-&amp;gt;instance()&lt;/code&gt; or &lt;code&gt;$this-&amp;gt;mock()&lt;/code&gt;. The real problem was the order-specific logic (prefix, lock, year scope) living in a model hook. When I wanted to test the edge cases (a locked id, too many attempts), I had to pull that logic out first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Actions/Order/OrderCreateAction.php, today&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderCreateAction&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Actionable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;OrderPrimaryKeyGenerator&lt;/span&gt; &lt;span class="nv"&gt;$orderPrimaryKeyGenerator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OrderCreateDTO&lt;/span&gt; &lt;span class="nv"&gt;$orderCreateDTO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$managerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Order&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;orderPrimaryKeyGenerator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$orderData&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$orderData&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'number'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off: the model has &lt;code&gt;$incrementing = false&lt;/code&gt;, so an &lt;code&gt;Order::create()&lt;/code&gt; that skips the action gets no id from Laravel. In our case both order create actions inject the generator, and the factory sets its own id.&lt;/p&gt;

&lt;p&gt;And now the generator gets its own test, no order creation needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;test_order_pk_skips_locked_id_and_tries_next&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Arrange&lt;/span&gt;
    &lt;span class="nv"&gt;$year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$lockedId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;intval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$year&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'000001'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$lockedId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Act&lt;/span&gt;
    &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderPrimaryKeyGenerator&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Assert&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;intval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$year&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'000002'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, the generator still uses the &lt;code&gt;Cache&lt;/code&gt; and &lt;code&gt;Date&lt;/code&gt; facades, &lt;code&gt;config()&lt;/code&gt;, and &lt;code&gt;Order::query()&lt;/code&gt; inside. So the test still boots the app and touches the database. That's the scope note from above: facades stayed, the hidden &lt;code&gt;app()&lt;/code&gt; lookup of our own class went.&lt;/p&gt;

&lt;p&gt;The commit that removed the last &lt;code&gt;app()&lt;/code&gt; call was titled "Test: Add failing unit tests for OrderPrimaryKeyGenerator". A test pushed it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  No Lint, No Blocking Review
&lt;/h2&gt;

&lt;p&gt;There was no PHPStan rule, no architecture test, no blocking review.&lt;/p&gt;

&lt;p&gt;Over the years I mentioned it to a colleague in code review a few times. More like a note than a rule. At some point I even admitted I use &lt;code&gt;app()&lt;/code&gt; myself sometimes and told him to do it his way. My own new calls kept coming for months after that. By &lt;code&gt;git blame&lt;/code&gt;, I added more of these calls than he did. So this isn't a story about fixing someone else's code. We both wrote these calls and we both deleted them.&lt;/p&gt;

&lt;p&gt;A written rule did appear eventually. "Avoid &lt;code&gt;app()&lt;/code&gt;, prefer constructor injection" went into our project guidelines in April 2026, when exactly one call was left. Two weeks later, that one was gone too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where You Don't Need app() (And Where It's Fine)
&lt;/h2&gt;

&lt;p&gt;Before reaching for &lt;code&gt;app()&lt;/code&gt;, check if Laravel already calls your method through the container. It does in more places than most people think:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;handle()&lt;/code&gt; in commands and queued jobs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rules()&lt;/code&gt; and &lt;code&gt;authorize()&lt;/code&gt; in form requests (documented)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;after()&lt;/code&gt; in form requests (not documented, but it goes through &lt;code&gt;container-&amp;gt;call()&lt;/code&gt; too)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We once had this in a few form requests (December 2024):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Http/Requests/.../CartItemImportRequest.php, 2024&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @var CartValidateImportFileAction $cartValidateImportFileAction */&lt;/span&gt;
    &lt;span class="nv"&gt;$cartValidateImportFileAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CartValidateImportFileAction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Validator&lt;/span&gt; &lt;span class="nv"&gt;$validator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cartValidateImportFileAction&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$cartValidateImportFileAction&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'import_file'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We ended up dropping that hook for other reasons, so there is no real "after" version. But it never needed &lt;code&gt;App::make()&lt;/code&gt;. This is how it could have looked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// how it could have looked: method injection&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CartValidateImportFileAction&lt;/span&gt; &lt;span class="nv"&gt;$validateImportFile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Validator&lt;/span&gt; &lt;span class="nv"&gt;$validator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validateImportFile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$validateImportFile&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$validator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'import_file'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heads up: the docs mention dependency injection for &lt;code&gt;rules()&lt;/code&gt; and &lt;code&gt;authorize()&lt;/code&gt;, not for &lt;code&gt;after()&lt;/code&gt;. I checked it on Laravel 12.43.1: the injected class is resolved and the hook runs. I didn't check older versions. One more gotcha: Laravel passes the validator to &lt;code&gt;after()&lt;/code&gt; by name, so don't call your injected parameter &lt;code&gt;$validator&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Where &lt;code&gt;app()&lt;/code&gt; is OK: service providers, obviously. Code that Laravel builds without the container, like models, when you really can't move the logic out. Lazy resolution of something heavy you only need in one branch. That's a short list. Most of the rest, in my experience, should have been a constructor parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Rule of Thumb
&lt;/h2&gt;

&lt;p&gt;Not a rule for the team, just what the history taught me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a class calls &lt;code&gt;app()&lt;/code&gt; for one of your own services, that service belongs in the constructor.&lt;/li&gt;
&lt;li&gt;If a model needs &lt;code&gt;app()&lt;/code&gt;, move the logic to a service or an action.&lt;/li&gt;
&lt;li&gt;If you can't control the constructor, try method injection first. &lt;code&gt;app()&lt;/code&gt; comes last.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app(Foo::class)&lt;/code&gt; is resolved by the container, but it's still the Service Locator pattern. DI means the dependency arrives through the signature.&lt;/li&gt;
&lt;li&gt;In one real project, it went from a peak of 18 calls to 0 while the code roughly tripled. Many calls simply died with old code.&lt;/li&gt;
&lt;li&gt;The last one was in a model hook, and writing a test for it pushed it out. The written rule landed when only that one was left.&lt;/li&gt;
&lt;li&gt;Form request &lt;code&gt;after()&lt;/code&gt; accepts method injection too (undocumented, verified on Laravel 12.43.1).&lt;/li&gt;
&lt;/ul&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/12.x/container#automatic-injection" rel="noopener noreferrer"&gt;Laravel docs: Automatic Injection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/12.x/validation#performing-additional-validation-on-form-requests" rel="noopener noreferrer"&gt;Laravel docs: Performing Additional Validation on Form Requests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/" rel="noopener noreferrer"&gt;Mark Seemann: Service Locator is an Anti-Pattern&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.jimmybogard.com/service-locator-is-not-an-anti-pattern/" rel="noopener noreferrer"&gt;Jimmy Bogard: Service Locator is not an Anti-Pattern&lt;/a&gt; (a fair counterpoint)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/adelf/acwa_book_en/blob/master/manuscript/2-di.md" rel="noopener noreferrer"&gt;Adel Faizrakhmanov: Architecture of Complex Web Applications, Dependency Injection chapter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/tegos/laravel-actions-and-services-360d"&gt;Laravel Actions and Services&lt;/a&gt; (my earlier post on where this logic goes)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Schema-Valid Is Not Complete: Strict Output Answered 8 of 30</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:14:24 +0000</pubDate>
      <link>https://dev.to/tegos/schema-valid-is-not-complete-strict-output-answered-8-of-30-8ie</link>
      <guid>https://dev.to/tegos/schema-valid-is-not-complete-strict-output-answered-8-of-30-8ie</guid>
      <description>&lt;p&gt;My translation job told me it had translated 8 product names out of 30. The response was valid JSON, it matched my strict schema, and nothing threw. That was the problem.&lt;/p&gt;

&lt;p&gt;Structured Outputs promises that the model always follows your JSON Schema. Turns out "follows the schema" and "answered every question" are two different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;In a project I work on, product names come from an ERP feed in Ukrainian, and a queued job translates them with OpenAI. The job sends names in chunks of 30, numbered in the prompt, and asks &lt;code&gt;gpt-4o-mini&lt;/code&gt; for a structured answer: &lt;code&gt;response_format&lt;/code&gt; with a JSON Schema and &lt;code&gt;strict: true&lt;/code&gt;. With strict on, the model can only produce JSON that matches the schema.&lt;/p&gt;

&lt;p&gt;This was the response format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BEFORE&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;responseFormat&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'json_schema'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'json_schema'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'product_translations'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'strict'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'schema'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'additionalProperties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'translations'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'translations'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                            &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="s1"&gt;'additionalProperties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                            &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                                &lt;span class="s1"&gt;'n'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                &lt;span class="s1"&gt;'en'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                            &lt;span class="p"&gt;],&lt;/span&gt;
                        &lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks safe. Strict mode is on, and every item needs a number and a translation. The prompt also said, in plain words, "return exactly as many items as there are texts".&lt;/p&gt;

&lt;p&gt;Then I ran a local bench on 2000 real names before rollout. One chunk of 30 long names came back with &lt;strong&gt;8 translations&lt;/strong&gt;. I sent that chunk four times: 8 of 30 every time, with the same 267 completion tokens. In a later probe it did come back complete, but only once in 5 attempts.&lt;/p&gt;

&lt;p&gt;And the job was happy with it. It wrote 8 rows and logged the other 22 as rejected. No exception, no retry, nothing red. Over the whole bench, 22 names were lost per run this way.&lt;/p&gt;

&lt;p&gt;Could a count check with a retry save it? Not really. The same chunk gave the same short answer almost every time, so a retry loop just pays for the same 8 rows again. A count check is still worth having, but as a backstop, not as the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Strict Mode Let It Through
&lt;/h2&gt;

&lt;p&gt;Let me be real: strict mode did exactly what it promised. The schema says &lt;code&gt;translations&lt;/code&gt; is an array of &lt;code&gt;{n, en}&lt;/code&gt; objects. It says nothing about how long that array is. An array of 8 items is valid. So is an empty one.&lt;/p&gt;

&lt;p&gt;Strict mode guarantees the &lt;strong&gt;shape&lt;/strong&gt; of the answer. It does not guarantee that every input got an answer. The "return exactly 30 items" rule lived only in the prompt, and the model can ignore the prompt.&lt;/p&gt;

&lt;p&gt;Strict mode also can't help when the answer hits the token limit (&lt;code&gt;finish_reason: length&lt;/code&gt;) or the model refuses. Then you get cut-off JSON or no JSON at all, so check &lt;code&gt;finish_reason&lt;/code&gt; before you parse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: One Required Key per Input
&lt;/h2&gt;

&lt;p&gt;The fix is simple: make the schema depend on the input. Instead of a free-length array, the answer is an object with &lt;strong&gt;one property per row number&lt;/strong&gt;. Strict mode makes every property required, so the number of properties is now the number of answers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AFTER&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;responseFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'json_schema'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'json_schema'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'product_translations'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'strict'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'schema'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'additionalProperties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_combine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nv"&gt;$keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="nb"&gt;array_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a chunk of 3, the schema becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"3"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a short answer is not a valid answer. The model can't skip row 17, because &lt;code&gt;"17"&lt;/code&gt; is required, and it can't answer row 17 twice, because an object has only one &lt;code&gt;"17"&lt;/code&gt; key.&lt;/p&gt;

&lt;p&gt;The call builds the schema from the chunk it sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o-mini'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'response_format'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;responseFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ukTexts&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heads up: return early on an empty chunk. &lt;code&gt;range(1, 0)&lt;/code&gt; is &lt;code&gt;[1, 0]&lt;/code&gt; in PHP, and &lt;code&gt;array_combine()&lt;/code&gt; throws. One limit to know: the docs cap a schema at 5000 object properties in total. Fine for chunks of 30, but this pattern doesn't scale to a chunk of 10,000.&lt;/p&gt;

&lt;p&gt;The parser reads the row number straight from the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$en&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$en&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$number&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// The prompt numbers from 1, the caller indexes from 0.&lt;/span&gt;
        &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$number&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$en&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;json_decode()&lt;/code&gt; turns &lt;code&gt;"1"&lt;/code&gt;, &lt;code&gt;"2"&lt;/code&gt; into integer array keys, so &lt;code&gt;$number&lt;/code&gt; arrives as an &lt;code&gt;int&lt;/code&gt;. The &lt;code&gt;is_numeric()&lt;/code&gt; check only drops non-numeric keys, which is enough when strict mode already rejects any key you didn't list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About minItems?
&lt;/h2&gt;

&lt;p&gt;Why not keep the array and add &lt;code&gt;minItems: 30&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;When I made this fix, I believed OpenAI rejects &lt;code&gt;minItems&lt;/code&gt; in strict mode. That was true in 2024 (there's a community thread about it). &lt;strong&gt;It's not true today.&lt;/strong&gt; The current docs list &lt;code&gt;minItems&lt;/code&gt; and &lt;code&gt;maxItems&lt;/code&gt; as supported in strict mode, except for fine-tuned models. I also checked it with a live call: &lt;code&gt;gpt-4o-mini&lt;/code&gt;, strict, &lt;code&gt;minItems: 30, maxItems: 30&lt;/code&gt;. The API accepted it and returned 30 items.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;minItems&lt;/code&gt; is a real option now. But it pins only the &lt;strong&gt;count&lt;/strong&gt;. Thirty items with &lt;code&gt;n&lt;/code&gt; values &lt;code&gt;1, 2, 2, 4...&lt;/code&gt; are still valid: row 2 has two answers and row 3 has none. Your parser overwrites row 2, loses row 3, and a count check on the response still says 30 of 30.&lt;/p&gt;

&lt;p&gt;With a key per row, that answer can't pass: row 17 has exactly one slot, and it is required. What the model puts in that slot is still up to the model. Row 18's translation under &lt;code&gt;"17"&lt;/code&gt;, or an empty string, passes either schema. That's the job of the quality guard, not the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Same 2000-name sample, same model, same prompt, before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Free-length array&lt;/th&gt;
&lt;th&gt;Key per row&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Problem chunk (30 long names)&lt;/td&gt;
&lt;td&gt;8/30 on 4 of 4 attempts&lt;/td&gt;
&lt;td&gt;30/30 on 4 of 4 attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Names lost by the model, per run&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Names translated and accepted&lt;/td&gt;
&lt;td&gt;1974&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1997&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected by the quality guard&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rejected ones are a different story: those are bad translations caught by a separate guard, and that's fine. The lost ones were never there to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;strict: true&lt;/code&gt; guarantees the shape of the JSON, not that every input got an answer.&lt;/li&gt;
&lt;li&gt;A free-length array lets the model answer short and still pass the schema.&lt;/li&gt;
&lt;li&gt;Build the schema per request: one required property per input row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;minItems&lt;/code&gt; is supported in strict mode today, but it pins only the count: duplicate and missing row numbers still pass.&lt;/li&gt;
&lt;li&gt;Keys stop skipped and doubled rows, not wrong content. Keep a &lt;code&gt;finish_reason&lt;/code&gt; check and a quality guard behind them.&lt;/li&gt;
&lt;li&gt;If a rule lives only in the prompt, assume the model will break it on some chunk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;💡 If your structured output has a list that should match your input, ask yourself: can a shorter answer pass this schema? If yes, assume it will.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/guides/structured-outputs" rel="noopener noreferrer"&gt;OpenAI: Structured model outputs, supported schemas&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.openai.com/t/min-maxitems-are-not-supported-in-structured-output/958567" rel="noopener noreferrer"&gt;OpenAI community: "min- &amp;amp; maxItems are not supported in Structured Output" (2024)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.com/index/introducing-structured-outputs-in-the-api/" rel="noopener noreferrer"&gt;OpenAI: Introducing Structured Outputs in the API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openai-php/client" rel="noopener noreferrer"&gt;openai-php/client&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>openai</category>
      <category>laravel</category>
      <category>ai</category>
    </item>
    <item>
      <title>Mail::alwaysTo() Guards One Mailer: The Staging Leak It Leaves Open</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Fri, 09 Oct 2026 21:23:02 +0000</pubDate>
      <link>https://dev.to/tegos/mailalwaysto-guards-one-mailer-the-staging-leak-it-leaves-open-18pc</link>
      <guid>https://dev.to/tegos/mailalwaysto-guards-one-mailer-the-staging-leak-it-leaves-open-18pc</guid>
      <description>&lt;p&gt;A few weeks ago I saved a link about keeping test emails away from real users. The plan was simple: add &lt;code&gt;Mail::alwaysTo()&lt;/code&gt; to our staging setup and stop worrying. The Laravel docs list it for local development, and Laravel News plus at least five other blog posts I found carry it over to staging. None of them mention a limit.&lt;/p&gt;

&lt;p&gt;Before shipping it, I opened the framework source to see what that call actually does. Turns out, it guards only one of your mailers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Safety Net Everyone Copies
&lt;/h2&gt;

&lt;p&gt;Here is the usual version, adapted from the docs and wrapped in an environment check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Mail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isProduction&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;alwaysTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'staging@example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every message sent through it gets its &lt;code&gt;to&lt;/code&gt; replaced, and &lt;code&gt;cc&lt;/code&gt; and &lt;code&gt;bcc&lt;/code&gt; are removed. Looks complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Now add a second mailer. Say transactional mail goes through the default one and invoices through another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/mail.php&lt;/span&gt;
&lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_MAILER'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'smtp'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

&lt;span class="s1"&gt;'mailers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'smtp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'billing'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'transport'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'postmark'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested this in a sandbox with Laravel 12.43, both mailers on the &lt;code&gt;array&lt;/code&gt; transport, and &lt;code&gt;Mail::alwaysTo('staging@example.com')&lt;/code&gt; in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'client@example.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvoiceMail&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// sent to: staging@example.com&lt;/span&gt;

&lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'client@example.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvoiceMail&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// sent to: client@example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notifications have the same hole. &lt;code&gt;MailMessage&lt;/code&gt; lets you pick a mailer too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toMail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="nv"&gt;$notifiable&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;MailMessage&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MailMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'billing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Your invoice is ready.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// sent to: the real customer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with scrubbed staging data, real addresses slip through: admins, seeded clients, imported leads. Each one gets the invoice.&lt;/p&gt;

&lt;p&gt;Pointing &lt;code&gt;MAIL_MAILER&lt;/code&gt; at Mailpit or &lt;code&gt;log&lt;/code&gt; on staging doesn't close this either. It swaps the default mailer only, so if staging has the real Postmark key, &lt;code&gt;Mail::mailer('billing')&lt;/code&gt; still uses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Happens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Mail::alwaysTo()&lt;/code&gt; is not a global setting. The facade points to &lt;code&gt;MailManager&lt;/code&gt;, and &lt;code&gt;MailManager&lt;/code&gt; forwards unknown calls to the &lt;strong&gt;default&lt;/strong&gt; mailer only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illuminate\Mail\MailManager&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$parameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;$method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$parameters&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;alwaysTo&lt;/code&gt; is stored on one &lt;code&gt;Mailer&lt;/code&gt; instance. When you ask for &lt;code&gt;Mail::mailer('billing')&lt;/code&gt;, the manager builds a separate instance in &lt;code&gt;resolve()&lt;/code&gt;, and that instance gets its global addresses from config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'from'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reply_to'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'to'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'return_path'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setGlobalAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mailer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setGlobalAddress()&lt;/code&gt; reads &lt;code&gt;mail.mailers.billing.to&lt;/code&gt; first, then falls back to &lt;code&gt;mail.to&lt;/code&gt;. Your &lt;code&gt;alwaysTo&lt;/code&gt; call is never part of that.&lt;/p&gt;

&lt;p&gt;This isn't new. &lt;a href="https://github.com/laravel/framework/issues/44024" rel="noopener noreferrer"&gt;Issue #44024&lt;/a&gt; described the same thing in 2022 and was closed as a support question. The logic is the same in Laravel 12 and on the 13.x branch.&lt;/p&gt;

&lt;p&gt;There is a second way it fails, under Octane. Service providers boot once, but Octane forgets the resolved mailers between requests, so the redirect set in &lt;code&gt;boot()&lt;/code&gt; is lost even in a single-mailer app. I didn't run Octane for this one. A Laravel maintainer confirmed it in &lt;a href="https://github.com/laravel/framework/discussions/53727" rel="noopener noreferrer"&gt;discussion #53727&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Put the redirect where every configured mailer reads it: the config. &lt;code&gt;config/mail.php&lt;/code&gt; has no &lt;code&gt;to&lt;/code&gt; key by default, but &lt;code&gt;MailManager&lt;/code&gt; picks one up if you add it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/mail.php&lt;/span&gt;
&lt;span class="s1"&gt;'to'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'address'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_TO_ADDRESS'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_TO_NAME'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Staging Inbox'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env on staging only&lt;/span&gt;
&lt;span class="nv"&gt;MAIL_TO_ADDRESS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;staging@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the same sandbox, both mailers now sent to &lt;code&gt;staging@example.com&lt;/code&gt;. The address is applied each time a mailer is built, so it also survives &lt;code&gt;Mail::forgetMailers()&lt;/code&gt;, which is what Octane does between requests. I checked that part with a plain call, not a running Octane server.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;MAIL_TO_ADDRESS&lt;/code&gt; is not set, nothing changes, so production is unaffected without any &lt;code&gt;if&lt;/code&gt; in a provider. Leave the key out of the production &lt;code&gt;.env&lt;/code&gt; entirely. An empty &lt;code&gt;MAIL_TO_ADDRESS=&lt;/code&gt; is still a value, and every send then fails with &lt;code&gt;RfcComplianceException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One trade-off: this fails open. Forget the variable on a new staging box and mail goes out for real. I'd add a guard for that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'staging'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;blank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mail.to.address'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MAIL_TO_ADDRESS must be set on staging.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heads up: two exceptions. A mailer with its own &lt;code&gt;to&lt;/code&gt; key in &lt;code&gt;mail.mailers&lt;/code&gt; wins over the global one, even &lt;code&gt;'to' =&amp;gt; null&lt;/code&gt;. And a mailer built on the fly with &lt;code&gt;Mail::build([...])&lt;/code&gt; skips global addresses completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Test That Keeps It Fixed
&lt;/h2&gt;

&lt;p&gt;The per-mailer &lt;code&gt;to&lt;/code&gt; is the one that bites later. Someone adds a &lt;code&gt;to&lt;/code&gt; to one mailer months later, never having read this config. So I'd rather have a test that checks every mailer against the real config. First, give the test environment the address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- phpunit.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;env&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"MAIL_TO_ADDRESS"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"staging@example.com"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Mail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Tests\TestCase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MailRedirectTest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;TestCase&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;test_every_mailer_sends_to_the_staging_inbox&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Arrange&lt;/span&gt;
        &lt;span class="nv"&gt;$mailers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mail.mailers'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mailers&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"mail.mailers.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.transport"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forgetMailers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mailers&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Act&lt;/span&gt;
            &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ping'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'client@example.com'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

            &lt;span class="c1"&gt;// Assert&lt;/span&gt;
            &lt;span class="nv"&gt;$sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSymfonyTransport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getOriginalMessage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'staging@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTo&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAddress&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;"Mailer [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] escaped the redirect"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It swaps every transport to &lt;code&gt;array&lt;/code&gt;, so nothing is actually sent. &lt;code&gt;Mail::forgetMailers()&lt;/code&gt; matters here: a mailer resolved before the test keeps its old transport, and with a &lt;code&gt;log&lt;/code&gt; default the assertion crashes on &lt;code&gt;LogTransport&lt;/code&gt;, which has no &lt;code&gt;messages()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With the nine mailers from the default config (smtp, ses, postmark, resend, sendmail, log, array, failover, roundrobin) it passes. Give &lt;code&gt;billing&lt;/code&gt; its own &lt;code&gt;to&lt;/code&gt; and it fails with &lt;code&gt;Mailer [billing] escaped the redirect&lt;/code&gt;. Delete the &lt;code&gt;to&lt;/code&gt; block from &lt;code&gt;config/mail.php&lt;/code&gt; and it fails on &lt;code&gt;smtp&lt;/code&gt;. Don't use &lt;code&gt;Mail::fake()&lt;/code&gt; here: the fake replaces the manager, so it can't tell you anything about redirects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Mail::alwaysTo()&lt;/code&gt; is fine on your laptop, with one mailer and no Octane. For staging, where real addresses live in the database, I'd use &lt;code&gt;mail.to&lt;/code&gt; in config, a boot guard for the missing variable, and a test that walks every mailer.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/12.x/mail#mail-and-local-development" rel="noopener noreferrer"&gt;Laravel docs: Mail and Local Development&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/laravel/framework/blob/13.x/src/Illuminate/Mail/MailManager.php" rel="noopener noreferrer"&gt;MailManager source on the 13.x branch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/laravel/framework/issues/44024" rel="noopener noreferrer"&gt;laravel/framework#44024: Mail::alwaysTo() is not overriding mailable&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/laravel/framework/discussions/53727" rel="noopener noreferrer"&gt;laravel/framework#53727: Mail::alwaysTo not working for synced mails (Octane)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.conroyp.com/articles/redirecting-test-emails-safely-in-laravel" rel="noopener noreferrer"&gt;Redirecting Test Emails Safely in Laravel, by Paul Conroy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
      <category>email</category>
    </item>
    <item>
      <title>hash_equals('', '') Is true: When an Empty Config Opens Basic Auth</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Fri, 21 Aug 2026 20:10:00 +0000</pubDate>
      <link>https://dev.to/tegos/hashequals-is-true-when-an-empty-config-opens-basic-auth-34c6</link>
      <guid>https://dev.to/tegos/hashequals-is-true-when-an-empty-config-opens-basic-auth-34c6</guid>
      <description>&lt;p&gt;I wasn't hunting for this. I was reading a Basic Auth middleware in a project I work on, checking something unrelated, and I stopped on one line. The comparison used &lt;code&gt;hash_equals()&lt;/code&gt;, which is the right function. The credentials came from config, which is the right place. And if that config was empty, the middleware let anyone in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Here's the shape of it. A small internal API, protected by hand-rolled HTTP Basic Auth instead of the full auth stack, because the caller is another service and there's no user to log in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Http\Middleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Closure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Http\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EnsureBasicAuth&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$expectedUsername&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.internal_api.username'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$expectedPassword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.internal_api.password'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$usernameOk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedUsername&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="nv"&gt;$passwordOk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedPassword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPassword&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$usernameOk&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$passwordOk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unauthorized'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTTP_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the method name. In a Laravel codebase &lt;code&gt;$request-&amp;gt;getUser()&lt;/code&gt; isn't &lt;code&gt;$request-&amp;gt;user()&lt;/code&gt;. It's Symfony's, and it returns the Basic Auth username as a string, or &lt;code&gt;null&lt;/code&gt; when there's no &lt;code&gt;Authorization&lt;/code&gt; header at all.&lt;/p&gt;

&lt;p&gt;Nothing in that class is a typo. &lt;code&gt;hash_equals()&lt;/code&gt; is the function everyone tells you to use, and the user input sits in the second argument, which is the correct order.&lt;/p&gt;

&lt;p&gt;Now put a route behind it and send three kinds of request at it, while &lt;code&gt;config/services.php&lt;/code&gt; reads those two values from the environment. Both credentials move together here, because they come from the same &lt;code&gt;.env&lt;/code&gt; block and a fresh copy of &lt;code&gt;.env.example&lt;/code&gt; leaves both blank:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Both credentials&lt;/th&gt;
&lt;th&gt;No &lt;code&gt;Authorization&lt;/code&gt; header&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;Basic Og==&lt;/code&gt; (blank user, blank password)&lt;/th&gt;
&lt;th&gt;admin + wrong password&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;empty strings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;missing entirely&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;s3cret&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the top row. An empty credential doesn't lock the door, it removes it, and a request carrying nothing at all gets a &lt;code&gt;200&lt;/code&gt;. Meanwhile the check most of us actually run, "let me try a wrong password", returns a confident &lt;code&gt;401&lt;/code&gt; and reports that the guard works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hash_equals Says Yes
&lt;/h2&gt;

&lt;p&gt;Three small things line up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hash_equals('', '')&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, and that's not a bug. The function compares two strings and two empty strings are equal. What it protects is the timing of the comparison, so an attacker can't learn the secret byte by byte. (It leaks a little even there: a length mismatch returns early, so the length of your secret is visible in the timing. The manual says so.) None of that has anything to do with whether your secret exists.&lt;/p&gt;

&lt;p&gt;A request with no &lt;code&gt;Authorization&lt;/code&gt; header gives you &lt;code&gt;null&lt;/code&gt; from &lt;code&gt;getUser()&lt;/code&gt;, and the &lt;code&gt;(string)&lt;/code&gt; cast turns &lt;code&gt;null&lt;/code&gt; into &lt;code&gt;''&lt;/code&gt;. So an attacker doesn't even need to send &lt;code&gt;Basic Og==&lt;/code&gt;. Sending nothing produces the same empty string.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;env()&lt;/code&gt; default never fires. The Laravel docs are exact about this: the second argument to &lt;code&gt;env()&lt;/code&gt; "will be returned if no environment variable exists for the given key". A bare &lt;code&gt;INTERNAL_API_USER=&lt;/code&gt; line means the key does exist, holding an empty string, so &lt;code&gt;env('INTERNAL_API_USER', 'fallback')&lt;/code&gt; hands you &lt;code&gt;''&lt;/code&gt; and not &lt;code&gt;'fallback'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the state a box is in right after someone copies &lt;code&gt;.env.example&lt;/code&gt; over and hasn't filled it in yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 500 Is the Warning, Not the Bug
&lt;/h2&gt;

&lt;p&gt;Now the middle row. Drop those two lines out of &lt;code&gt;.env&lt;/code&gt; completely and &lt;code&gt;config()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;, so &lt;code&gt;hash_equals()&lt;/code&gt; gets a &lt;code&gt;null&lt;/code&gt; and throws:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: hash_equals(): Argument #1 ($known_string) must be of type string, null given
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heads up: &lt;code&gt;declare(strict_types=1)&lt;/code&gt; has nothing to do with this. &lt;code&gt;hash_equals()&lt;/code&gt; checks its argument types itself rather than letting PHP coerce them, so it rejects every non-string in both weak and strict mode, and it names the type it got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hash_equals(): Argument #1 ($known_string) must be of type string, int given
hash_equals(): Argument #1 ($known_string) must be of type string, true given
hash_equals(): Argument #1 ($known_string) must be of type string, array given
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 500 is an ugly way to refuse a request, but it does refuse it. And that's the trap, because the obvious way to make the noise stop is a cast on the expected value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't. This is the whole vulnerability in two characters.&lt;/span&gt;
&lt;span class="nv"&gt;$expectedUsername&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.internal_api.username'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$expectedPassword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.internal_api.password'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;null&lt;/code&gt; becomes &lt;code&gt;''&lt;/code&gt; before it ever reaches &lt;code&gt;hash_equals()&lt;/code&gt;, the exception is gone, and the middle row of that table turns from &lt;code&gt;500&lt;/code&gt; into &lt;code&gt;200&lt;/code&gt;. Someone tidying up their error tracker just opened the endpoint to the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Refuse to compare at all when there's nothing to compare against. Two guards, both above the &lt;code&gt;hash_equals()&lt;/code&gt; calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;credentialsMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Unauthorized'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTTP_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'WWW-Authenticate'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Basic realm="Internal"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;credentialsMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$expectedUsername&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.internal_api.username'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$expectedPassword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.internal_api.password'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedUsername&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedPassword&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedUsername&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$expectedPassword&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$usernameOk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedUsername&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nv"&gt;$passwordOk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedPassword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPassword&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$usernameOk&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$passwordOk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;hash_equals()&lt;/code&gt; calls run before the &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; sees anything, so neither comparison gets skipped and the timing stays flat. The &lt;code&gt;WWW-Authenticate&lt;/code&gt; header is a small bonus fix: a bare &lt;code&gt;401&lt;/code&gt; without it isn't valid HTTP.&lt;/p&gt;

&lt;p&gt;Three things I'd add around it in production, none of which belong inside the middleware:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate the config at deploy time. A misconfigured guard returns &lt;code&gt;401&lt;/code&gt; to everybody, which is safe but completely silent. You find out when the other service starts alerting. A boot-time assertion, or a &lt;code&gt;.env&lt;/code&gt; validation step in the pipeline, tells you first.&lt;/li&gt;
&lt;li&gt;Keep it behind TLS and a throttle. Basic Auth is base64, not encryption, and nothing here slows down someone guessing passwords. &lt;code&gt;-&amp;gt;middleware('throttle:10,1')&lt;/code&gt; costs nothing.&lt;/li&gt;
&lt;li&gt;Read config, never &lt;code&gt;env()&lt;/code&gt;, inside a middleware. Turns out this is a second trap: after &lt;code&gt;php artisan config:cache&lt;/code&gt; the &lt;code&gt;.env&lt;/code&gt; file isn't loaded at all, and &lt;code&gt;env()&lt;/code&gt; returns only real system-level environment variables. A value that lives only in &lt;code&gt;.env&lt;/code&gt; comes back &lt;code&gt;null&lt;/code&gt; on a cached deploy, which drops you straight into the middle row of that table.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Test
&lt;/h2&gt;

&lt;p&gt;The test that catches this doesn't test the password. It tests the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Test]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;it_rejects_everyone_when_the_credentials_are_empty&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Arrange&lt;/span&gt;
    &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'services.internal_api.username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'services.internal_api.password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Act &amp;amp; Assert&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/internal/health'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertUnauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withBasicAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/internal/health'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertUnauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a second one setting both values to &lt;code&gt;null&lt;/code&gt;, and a third for the happy path. Against the original middleware, the first two go red and say exactly what the table said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ it allows the configured credentials
⨯ it rejects everyone when the credentials are empty
  Expected response status code [401] but received 200.
⨯ it rejects everyone when the credentials are missing
  Expected response status code [401] but received 500.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice which one passed. The happy-path test is green on the broken middleware and green on the fixed one, so a suite that only checks "right credentials in, wrong credentials out" never had a chance here.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hash_equals('', '')&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. Constant-time comparison guards the timing of a comparison, not the existence of a secret.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INTERNAL_API_USER=&lt;/code&gt; isn't a missing variable, it's an empty string, and the &lt;code&gt;env()&lt;/code&gt; default doesn't fire for it.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;Authorization&lt;/code&gt; header means &lt;code&gt;getUser()&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, and one &lt;code&gt;(string)&lt;/code&gt; cast on the expected side makes &lt;code&gt;null&lt;/code&gt; match it.&lt;/li&gt;
&lt;li&gt;If a misconfigured guard throws, don't cast the error away. Put an "is this configured at all" branch above every credential comparison.&lt;/li&gt;
&lt;li&gt;Test the unconfigured states. Wrong-password tests pass on broken code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;💡 If your callers are real users in a database, skip all of this and use Laravel's own &lt;code&gt;auth.basic&lt;/code&gt;, which can't get into an empty-secret state. Just know it wants a users table, keys on &lt;code&gt;email&lt;/code&gt; by default, and starts a session, so it's a poor fit for service-to-service calls.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.php.net/manual/en/function.hash-equals.php" rel="noopener noreferrer"&gt;PHP manual: hash_equals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/12.x/configuration#retrieving-environment-configuration" rel="noopener noreferrer"&gt;Laravel docs: retrieving environment configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/12.x/configuration#configuration-caching" rel="noopener noreferrer"&gt;Laravel docs: configuration caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/laravel/framework/issues/21727" rel="noopener noreferrer"&gt;laravel/framework#21727: env() returns null after config:cache&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>testing</category>
    </item>
    <item>
      <title>Hunting Elephants Again: Reviving a Dead Travian Bot with an AI Agent</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Fri, 17 Jul 2026 15:39:37 +0000</pubDate>
      <link>https://dev.to/tegos/hunting-elephants-again-reviving-a-dead-travian-bot-with-an-ai-agent-4b4l</link>
      <guid>https://dev.to/tegos/hunting-elephants-again-reviving-a-dead-travian-bot-with-an-ai-agent-4b4l</guid>
      <description>&lt;p&gt;I had a side project rotting in a private repo for three years. Last week I brought it back to life in a weekend, and an AI agent did most of the boring parts.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/tegos/travian-elephant-finder" rel="noopener noreferrer"&gt;travian-elephant-finder&lt;/a&gt;, a small Node tool for the browser game Travian. On the map, oases are tiles guarded by wild animals. Clear one and you get hero experience and a production bonus. &lt;strong&gt;Elephants&lt;/strong&gt; are the toughest guards, so they give the most hero XP, and if you attack with Cages equipped you &lt;em&gt;capture&lt;/em&gt; them as free defenders instead of killing them. So elephant oases are the best targets on the map. Finding them by hand means opening hundreds of tiles one by one. The tool scans the area around your village and hands you a sorted report of where the elephants are.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/L0IGe_Hd6R4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Yes, automating Travian is against its terms of service. I run this on a throwaway account, for fun, and the post is about the revival workflow, not a pitch to go bot your favorite game.&lt;/p&gt;

&lt;p&gt;Not Laravel, but the revival workflow is pure backend and it travels: a dead scrape swapped for a real API, an expensive loop trimmed by a cheap upfront query. The same moves you would make in any codebase, Laravel included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it died
&lt;/h2&gt;

&lt;p&gt;Games change their frontend, and scrapers rot. My login used to grab a bearer token out of an obfuscated inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; on the dashboard page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the old "auth": scrape a token out of eval(atob('...')) on /dorf1.php&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eval&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;atobString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;regExp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;atob(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;atobString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;binary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// fingers crossed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Travian moved to a new JS bundle and that &lt;code&gt;eval()&lt;/code&gt; pattern vanished from the page. The scrape returned nothing, the token refresh silently failed, and the bot just looped 401s forever. Classic dead side project: not broken loudly, broken quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the AI got right
&lt;/h2&gt;

&lt;p&gt;I pointed Claude Code at the repo and described the symptom: 401 loop, token never refreshes. It found the dead scrape, traced how the game's own client logs in, and replaced the whole fragile thing with the real login API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loginResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axiosDefaultInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/auth/login`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loginResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// exchange the code for a JWT session cookie, done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No token scrape, no manual cookie-paste from DevTools. This is the kind of work AI is genuinely good at: mechanical, well-defined, annoying. It also redesigned the ugly HTML report and cleaned up the CLI output, again in minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still had to figure out
&lt;/h2&gt;

&lt;p&gt;The agent did not hand me the good ideas. The best win in this rewrite was noticing that Travian publishes a public &lt;code&gt;map.sql&lt;/code&gt; dump of every village:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://ts5.x1.europe.travian.com/map.sql | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;span class="c"&gt;# INSERT INTO x_world VALUES (...);  -- villages only, no oases&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Villages never sit on oases, so any tile in that dump can be skipped before the scan even opens it. On my run that trimmed about 13% of the HTTP requests. The AI wrote the parser cleanly once I decided to use it, but the "wait, I can rule these tiles out for free" moment was mine. Same with the search model: I chose the "one center point, one distance" design that scans a circle around the village; the agent just implemented it.&lt;/p&gt;

&lt;p&gt;That is the split I keep seeing. AI removes the friction. You still bring the judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scraping JS-injected values is the first thing to break.&lt;/strong&gt; If the site has a real API, use it. My token scrape was dead code for who knows how long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule out work before you loop.&lt;/strong&gt; One &lt;code&gt;map.sql&lt;/code&gt; fetch listed every village, so the scan could skip those tiles instead of opening each one. It did not replace the per-tile loop, it pruned it, the same way filtering rows before a loop beats checking each inside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Here the AI did the grunt work and I did the thinking.&lt;/strong&gt; It resurrected the boring 80%. The 20% that made the tool better was still my call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A weekend is enough now.&lt;/strong&gt; Projects I would have left dead are worth reviving when the tedious part is cheap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I brought travian-elephant-finder back from three years dead in a weekend. An AI agent handled the mechanical rewrites (dead scrape to a real login API, report cleanup), but the calls that actually made it better, like skipping village tiles from the public &lt;code&gt;map.sql&lt;/code&gt; to trim the scan and picking the search model, were still mine.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/tegos/travian-elephant-finder" rel="noopener noreferrer"&gt;travian-elephant-finder on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.claude.com/en/docs/claude-code/overview" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/atob" rel="noopener noreferrer"&gt;MDN: window.atob()&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>bcrypt and Laravel: 72 Bytes, Not 72 Characters</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Sun, 21 Jun 2026 18:19:06 +0000</pubDate>
      <link>https://dev.to/tegos/bcrypt-and-laravel-72-bytes-not-72-characters-3jb1</link>
      <guid>https://dev.to/tegos/bcrypt-and-laravel-72-bytes-not-72-characters-3jb1</guid>
      <description>&lt;p&gt;I expected bcrypt to silently drop characters past 72. I did not expect it to bake in half an emoji.&lt;/p&gt;

&lt;p&gt;That's what happens with a specific password combination I tested. The original password still works. But strip the emoji (a password manager, a different keyboard, a Unicode normalizer) and you're locked out. Your Laravel validator passed it as valid the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 72-Byte Rule
&lt;/h2&gt;

&lt;p&gt;bcrypt has a hard input limit of 72 bytes. Not characters - bytes.&lt;/p&gt;

&lt;p&gt;When you call &lt;code&gt;password_hash($password, PASSWORD_BCRYPT)&lt;/code&gt;, PHP silently truncates anything past byte 72. Most developers know this in theory. But for ASCII-only apps, it never bites. 72 ASCII characters is already a very long password, and the silent clip is harmless in practice.&lt;/p&gt;

&lt;p&gt;The trouble starts with multi-byte scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Many Characters Fit?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Character set&lt;/th&gt;
&lt;th&gt;Bytes per char&lt;/th&gt;
&lt;th&gt;Effective bcrypt limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ASCII&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;72 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cyrillic&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;36 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CJK (Chinese, Japanese, Korean, common block)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;24 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emoji&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;18 chars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Past the byte limit, a longer password adds no security at all. A 200-character Cyrillic password hashes identically to its own first 36 characters. Byte 73 and beyond simply do not exist from bcrypt's point of view. So "longer always produces a stronger bcrypt hash" is not true.&lt;/p&gt;

&lt;p&gt;A Cyrillic user with a 37-character password gets silently truncated at char 36. The hash is still consistent. The user logs in fine, but any variation past character 36 doesn't matter to bcrypt. Annoying from a security standpoint, but it does not break login.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Split-Byte Trap
&lt;/h2&gt;

&lt;p&gt;The 72-byte limit cuts at a byte boundary, not a character boundary. If a multi-byte character falls on that cut, bcrypt bakes in an incomplete UTF-8 sequence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 35 Cyrillic chars = 70 bytes, emoji = 4 bytes, total = 74 bytes&lt;/span&gt;
&lt;span class="nv"&gt;$password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'А'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'🔑'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PASSWORD_BCRYPT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// ?&lt;/span&gt;
&lt;span class="nb"&gt;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str_repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'А'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ?&lt;/span&gt;
&lt;span class="nb"&gt;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str_repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'А'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// PHP 8.4, Laravel sandbox:
verify(original 74 bytes):          bool(true)
verify(trimmed 35 Cyrillic, 70 bytes): bool(false)
verify(36 Cyrillic, 72 bytes):      bool(false)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original full string still verifies. bcrypt truncates it to the same 72 bytes on both sides. But every other attempt fails.&lt;/p&gt;

&lt;p&gt;What happened: bcrypt stored 70 Cyrillic bytes plus the first 2 bytes of the 4-byte emoji (F0 9F from the sequence F0 9F 94 91). That's the effective password now. The trailing emoji looks like decoration, but it's load-bearing.&lt;/p&gt;

&lt;p&gt;I tested this in my Laravel sandbox across several character sets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Total bytes&lt;/th&gt;
&lt;th&gt;Original verifies?&lt;/th&gt;
&lt;th&gt;Cyrillic-only verifies?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;36x Cyrillic + extra chars&lt;/td&gt;
&lt;td&gt;72+2&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;24x CJK + extra chars&lt;/td&gt;
&lt;td&gt;72+3&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;18x emoji + extra chars&lt;/td&gt;
&lt;td&gt;72+4&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;35x Cyrillic + 1 emoji&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most overflow cases both the original and any Cyrillic-only variant work - the clip is harmless. With the split-byte case, only the exact original string works. A user who enters "35 Cyrillic chars" thinking the emoji was decoration is locked out. That happens when a password manager strips the emoji, a keyboard can't type it, or the app normalizes Unicode on input.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Laravel Does About It
&lt;/h2&gt;

&lt;p&gt;Laravel's &lt;code&gt;Password::max()&lt;/code&gt; uses &lt;code&gt;mb_strlen()&lt;/code&gt; under the hood. That counts characters, not bytes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks safe. Is not.&lt;/span&gt;
&lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Password&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 37-char Cyrillic password: &lt;code&gt;mb_strlen&lt;/code&gt; = 37, passes your validator. &lt;code&gt;strlen&lt;/code&gt; = 74. bcrypt gets 74 bytes and clips at 72. Depending on where the boundary lands, the user either gets silent truncation or the split-byte lockout.&lt;/p&gt;

&lt;p&gt;Laravel 12 added a &lt;code&gt;BCRYPT_LIMIT&lt;/code&gt; config option in &lt;code&gt;config/hashing.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/hashing.php&lt;/span&gt;
&lt;span class="s1"&gt;'bcrypt'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'rounds'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'BCRYPT_ROUNDS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'limit'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'BCRYPT_LIMIT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heads up: &lt;code&gt;BCRYPT_LIMIT&lt;/code&gt; was introduced in Laravel 12. On Laravel 11 and below, you need to handle this yourself.&lt;/p&gt;

&lt;p&gt;When set, Laravel's bcrypt driver throws an &lt;code&gt;InvalidArgumentException&lt;/code&gt; if the password exceeds 72 bytes. That prevents silent truncation. But it does not close the validation gap: &lt;code&gt;Password::max(72)&lt;/code&gt; still accepts passwords that exceed 72 bytes, and &lt;code&gt;BCRYPT_LIMIT&lt;/code&gt; then throws at hash time. Without explicit exception handling, that bubbles as a 500. Your user submitted a valid form and hits an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix Options
&lt;/h2&gt;

&lt;p&gt;Validate at the form layer, before hashing. bcrypt is intentionally slow: one unauthenticated request triggers a full key schedule regardless of input length. Without an upper bound, that cost is fixed per request whether the input is 10 bytes or 10 000. Rate limiting is still essential; the byte cap just removes wasted work on input bcrypt would ignore anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: Validate byte length directly
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'min:8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attribute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$fail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'The password is too long.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;strlen()&lt;/code&gt; in PHP counts bytes, not characters. This catches both the silent truncation and the split-byte case before hashing happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: Switch to Argon2id
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// config/hashing.php&lt;/span&gt;
&lt;span class="s1"&gt;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'argon2id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No 72-byte limit. For existing users registered with bcrypt, rehash on first successful login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Hash&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;needsRehash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Hash&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP's &lt;code&gt;password_verify()&lt;/code&gt; reads the algorithm from the hash directly, so existing bcrypt users can still log in after you change the default driver. Users log in fine throughout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: Pre-hash with SHA-256 before bcrypt
&lt;/h3&gt;

&lt;p&gt;OWASP's &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html" rel="noopener noreferrer"&gt;Password Storage Cheat Sheet&lt;/a&gt; covers this pattern. Their recommended form uses HMAC with a server-side pepper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Always produces 64 ASCII bytes - fits bcrypt comfortably&lt;/span&gt;
&lt;span class="nv"&gt;$prehashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pepper&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stored&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prehashed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PASSWORD_BCRYPT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Verify:&lt;/span&gt;
&lt;span class="nb"&gt;password_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$pepper&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$stored&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain &lt;code&gt;hash('sha256', $password)&lt;/code&gt; without a pepper has a known weakness: if bcrypt is cracked, the attacker gets the raw SHA-256 hash and can crack it without bcrypt's cost (password shucking). The HMAC form closes that gap. Either way, this is non-standard and adds complexity to your auth layer.&lt;/p&gt;

&lt;p&gt;If you're starting a new app, use Argon2id and skip this entire problem (check &lt;code&gt;defined('PASSWORD_ARGON2ID')&lt;/code&gt; first - some shared hosts and older builds lack it). If you're maintaining an existing bcrypt app, add the byte-length validator today and plan a migration to Argon2id when you have the runway.&lt;/p&gt;

&lt;p&gt;That user from the intro (the one locked out with a valid password) hit the split-byte case. The fix is one validator rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;bcrypt truncates at 72 &lt;strong&gt;bytes&lt;/strong&gt;, not characters. Cyrillic caps at 36 chars, CJK at 24, emoji at 18&lt;/li&gt;
&lt;li&gt;If the cut falls inside a multi-byte char, the trailing emoji becomes load-bearing: original verifies, anything that strips the emoji does not&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Password::max(72)&lt;/code&gt; uses &lt;code&gt;mb_strlen&lt;/code&gt; (characters, not bytes), so it does not catch this&lt;/li&gt;
&lt;li&gt;Laravel 12 &lt;code&gt;BCRYPT_LIMIT=72&lt;/code&gt; throws at hash time but does not block invalid input at the form level&lt;/li&gt;
&lt;li&gt;Fix: validate with &lt;code&gt;strlen($password) &amp;gt; 72&lt;/code&gt;, switch to Argon2id, or pre-hash with &lt;code&gt;hash_hmac('sha256', ...)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;💡 Use &lt;code&gt;strlen()&lt;/code&gt; to validate password byte length. &lt;code&gt;mb_strlen()&lt;/code&gt; won't catch the dangerous cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.php.net/manual/en/function.password-hash.php" rel="noopener noreferrer"&gt;PHP: password_hash&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.php.net/manual/en/function.password-verify.php" rel="noopener noreferrer"&gt;PHP: password_verify&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP: Password Storage Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://securinglaravel.com/security-tip-limiting-bcrypt-passwords-to-72-bytes/" rel="noopener noreferrer"&gt;securinglaravel.com: Limiting bcrypt Passwords to 72 Bytes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/laravel/framework/pull/54509" rel="noopener noreferrer"&gt;Laravel PR #54509: Add bcrypt byte limit&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>wayback-video: Turn Any Site's History into a Video</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Mon, 15 Jun 2026 14:30:08 +0000</pubDate>
      <link>https://dev.to/tegos/wayback-video-turn-any-sites-history-into-a-video-3542</link>
      <guid>https://dev.to/tegos/wayback-video-turn-any-sites-history-into-a-video-3542</guid>
      <description>&lt;p&gt;Side project outside my usual PHP world, but worth sharing.&lt;/p&gt;

&lt;p&gt;I have a habit of Googling old websites just to see what they looked like. GitHub circa 2008. Wikipedia in 2003. The Wayback Machine has it all, but clicking through snapshots one at a time never gives you the full picture of how a site evolved.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;wayback-video&lt;/strong&gt; - a Python CLI tool that takes a URL, pulls its entire Wayback Machine archive, renders each snapshot with a headless browser, and assembles everything into an MP4.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wayback-video https://github.com &lt;span class="nt"&gt;--scroll&lt;/span&gt; &lt;span class="nt"&gt;--interval&lt;/span&gt; year &lt;span class="nt"&gt;--from&lt;/span&gt; 2008
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. You get a video showing GitHub's design history from 2008 to today, one full-page scroll per year.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/a1pCBR3kmI4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The tool runs a 4-phase pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fetch.&lt;/strong&gt; Query the Wayback CDX API for all successful HTML captures of the URL. For a site archived monthly over 20 years, that's thousands of records. The tool collapses captures server-side by timestamp, then deduplicates by content digest locally, and selects a few candidates per time period.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sample.&lt;/strong&gt; Group captures by month, quarter, or year. Pick the best representative from each bucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Render.&lt;/strong&gt; Open each archived URL in Playwright using &lt;code&gt;if_&lt;/code&gt; replay mode (a Wayback URL prefix that strips the toolbar). Archived asset URLs are still rewritten to archive copies, so CSS, images, and scripts load from the archive, not the live server. Playwright takes a full-height screenshot.&lt;/p&gt;

&lt;p&gt;Then ffmpeg stitches it all into an MP4. Simple concat, crossfade, or scroll-pan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part That Breaks
&lt;/h2&gt;

&lt;p&gt;Rendering archived pages is fine until you hit SPAs. Any SPA that registered a Service Worker - when it was archived, the SW got archived too. When you replay it, the SW registers and starts intercepting requests, but now it points to the live origin, not the archive. The page breaks.&lt;/p&gt;

&lt;p&gt;wayback-video blocks Service Workers before the page loads. It also waits for JS to finish rendering after page load (default 2.5s - Playwright's network-idle events don't work reliably against Wayback's proxy, so a fixed wait is the practical fallback). If the resulting page has fewer than 200 characters of body text, it's probably a spinner or an archived 404 - so the tool skips it and tries the next candidate (the next archived snapshot from the same time bucket).&lt;/p&gt;

&lt;p&gt;Old static sites render fine. JS-heavy SPAs are hit or miss - depends on what Wayback actually captured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplication
&lt;/h2&gt;

&lt;p&gt;Not every year looks different. Some sites go through five years without a major redesign. Without dedup, you get the same frame repeated five times in a row.&lt;/p&gt;

&lt;p&gt;Two passes run automatically in &lt;code&gt;--scroll&lt;/code&gt; mode:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Exact dedup by SHA-256 hash. Byte-identical renders are dropped.&lt;/li&gt;
&lt;li&gt;Average hash (aHash) comparison. Consecutive frames with low visual distance get merged into one clip with a combined label like &lt;code&gt;2011-2014&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The threshold is configurable (&lt;code&gt;--similarity-threshold&lt;/code&gt;, default Hamming distance of 10, max 64). Raise it to merge more aggressively, lower it to keep subtle changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;When to reach for it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default (CDX)&lt;/td&gt;
&lt;td&gt;Fixed-viewport screenshots per period&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scroll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full-page height with pan animation (recommended)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--hybrid year&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wayback pre-captured PNGs first, Playwright as fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--wayback-screenshot year&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wayback PNGs only, no browser, fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--at-interval year&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One capture per year at a fixed date, via Availability API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Logo or image file evolution, no page render&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;--scroll&lt;/code&gt; is what I use by default. Full page height means you actually see the layout, not just the above-the-fold hero. Scroll speed is auto-calculated from page height, so nothing crawls or blurs past.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/tegos/wayback-video.git
&lt;span class="nb"&gt;cd &lt;/span&gt;wayback-video
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium

&lt;span class="c"&gt;# Ubuntu/Debian&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg

&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heads up: &lt;code&gt;playwright install chromium&lt;/code&gt; downloads ~130MB of Chromium - required even if you already have Chrome installed.&lt;/p&gt;

&lt;p&gt;Then pick a site you're curious about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Laravel's homepage through the years&lt;/span&gt;
wayback-video https://laravel.com &lt;span class="nt"&gt;--scroll&lt;/span&gt; &lt;span class="nt"&gt;--interval&lt;/span&gt; year &lt;span class="nt"&gt;--crossfade&lt;/span&gt; 0.4

&lt;span class="c"&gt;# Wikipedia, month by month, over its first decade&lt;/span&gt;
wayback-video https://wikipedia.org &lt;span class="nt"&gt;--scroll&lt;/span&gt; &lt;span class="nt"&gt;--interval&lt;/span&gt; month &lt;span class="nt"&gt;--from&lt;/span&gt; 2001 &lt;span class="nt"&gt;--to&lt;/span&gt; 2010

&lt;span class="c"&gt;# Fast mode: Wayback's pre-captured PNGs only, no browser needed&lt;/span&gt;
wayback-video https://mozilla.org &lt;span class="nt"&gt;--wayback-screenshot&lt;/span&gt; year &lt;span class="nt"&gt;--scroll&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requires Python 3.10+ and ffmpeg installed separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;wayback-video turns any site's Wayback Machine history into an MP4&lt;/li&gt;
&lt;li&gt;Archived pages render in &lt;code&gt;if_&lt;/code&gt; mode with Service Workers blocked so SPAs and assets load cleanly from the archive&lt;/li&gt;
&lt;li&gt;aHash dedup merges visually identical consecutive years into one labeled clip&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--scroll&lt;/code&gt; is what I use by default: full-page height, smooth pan animation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/tegos/wayback-video" rel="noopener noreferrer"&gt;github.com/tegos/wayback-video&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Composer Update Is Not Safe Anymore</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Fri, 12 Jun 2026 09:58:10 +0000</pubDate>
      <link>https://dev.to/tegos/composer-update-is-not-safe-anymore-2bcf</link>
      <guid>https://dev.to/tegos/composer-update-is-not-safe-anymore-2bcf</guid>
      <description>&lt;p&gt;Saturday morning. I opened Twitter and saw a tweet about the Laravel-Lang packages being compromised.&lt;/p&gt;

&lt;p&gt;My first reaction was simple: "I don't use that package."&lt;/p&gt;

&lt;p&gt;Then I opened &lt;code&gt;composer.json&lt;/code&gt; on a project I work on and found this in &lt;code&gt;require-dev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"laravel-lang/lang"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^14.8"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"laravel-lang/publisher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^16.8"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That changed things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happened
&lt;/h2&gt;

&lt;p&gt;The attack used &lt;code&gt;laravel-lang&lt;/code&gt; packages as the distribution channel. And the sneaky part: the main repository branch looked completely clean. No suspicious commits, no new code. The malicious payload was pushed via &lt;strong&gt;git tags on forks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Most developers would not notice anything. Just a routine &lt;code&gt;composer update&lt;/code&gt;, same as always.&lt;/p&gt;

&lt;p&gt;Once installed, the payload executed at &lt;strong&gt;autoload time&lt;/strong&gt;. That means every &lt;code&gt;php artisan&lt;/code&gt; command, queue worker, or web request running that codebase triggered the malware the moment PHP hit &lt;code&gt;require_once __DIR__.'/../vendor/autoload.php'&lt;/code&gt; in &lt;code&gt;public/index.php&lt;/code&gt;. Silently. No error, no red screen, nothing.&lt;/p&gt;

&lt;p&gt;The malware was a credential stealer. It searched the machine for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; files from Laravel projects&lt;/li&gt;
&lt;li&gt;AWS access keys and session tokens&lt;/li&gt;
&lt;li&gt;SSH private keys&lt;/li&gt;
&lt;li&gt;GitHub CLI tokens&lt;/li&gt;
&lt;li&gt;NPM tokens&lt;/li&gt;
&lt;li&gt;Infrastructure secrets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a SQL injection that messes with your database rows. This is a key stealer that runs on your machine and takes everything it finds.&lt;/p&gt;

&lt;p&gt;Aikido Security caught it and reported it to Packagist. Packagist removed the affected versions. But if you ran &lt;code&gt;composer update&lt;/code&gt; during that window, you were exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Supply Chain Attacks Are Different
&lt;/h2&gt;

&lt;p&gt;Classic Laravel security talks about SQL injection, XSS, CSRF. Those are attacks that come from outside users sending malicious input to your application.&lt;/p&gt;

&lt;p&gt;Supply chain attacks come from inside your own development process. The attacker does not need to find a vulnerability in your code. They need to compromise one developer account at one package maintainer. Every project depending on that package is now exposed.&lt;/p&gt;

&lt;p&gt;With AI tools, these attacks are getting more sophisticated and more frequent. The JavaScript ecosystem has been dealing with hundreds of similar incidents. PHP is catching up, unfortunately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Actually Changed
&lt;/h2&gt;

&lt;p&gt;On a project I work on, we run &lt;code&gt;composer update&lt;/code&gt; on a regular schedule. After this incident, I sat down and wrote it all out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The full workflow now looks like this:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Run composer update inside Docker&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app composer update

&lt;span class="c"&gt;# 2. Pin exact versions using jack raise-to-installed&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app vendor/bin/jack raise-to-installed &lt;span class="nt"&gt;--dry-run&lt;/span&gt;  &lt;span class="c"&gt;# preview first&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app vendor/bin/jack raise-to-installed             &lt;span class="c"&gt;# apply&lt;/span&gt;

&lt;span class="c"&gt;# 3. Update lock hash&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;app composer update &lt;span class="nt"&gt;--lock&lt;/span&gt;

&lt;span class="c"&gt;# 4. Commit everything&lt;/span&gt;
git add composer.json composer.lock
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"chore: update dependencies"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rector/jack&lt;/code&gt; is a tool by the Rector team that handles version management in &lt;code&gt;composer.json&lt;/code&gt;. The &lt;code&gt;raise-to-installed&lt;/code&gt; command takes whatever is currently installed and raises the constraints to match exactly. Instead of &lt;code&gt;"guzzlehttp/guzzle": "^7.10"&lt;/code&gt;, you get &lt;code&gt;"guzzlehttp/guzzle": "^7.11"&lt;/code&gt;. Each future &lt;code&gt;composer update&lt;/code&gt; will only reach what you explicitly allow. This is not standard Composer practice. It trades upgrade convenience for a narrower blast radius on unexpected jumps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One gotcha we hit:&lt;/strong&gt; on a Laravel 10 project, bare &lt;code&gt;composer update&lt;/code&gt; can fail with an advisory block. The fix is to add this to &lt;code&gt;composer.json&lt;/code&gt; config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"audit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"abandoned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"report"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"advisories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"block"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Advisories are reported, not blocking. You still see them and still have to act on them. The update just runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always verify what got bumped before committing.&lt;/strong&gt; On that project, it runs PHP 8.1, so packages that require 8.2+ cannot be allowed in. After &lt;code&gt;raise-to-installed&lt;/code&gt; dry run, check that nothing jumped to a version with a higher PHP floor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One important caveat:&lt;/strong&gt; this workflow would not have prevented the Laravel-Lang incident itself. By the time &lt;code&gt;jack raise-to-installed&lt;/code&gt; runs, the infected version is already installed. &lt;code&gt;raise-to-installed&lt;/code&gt; then pins that infected version as the new baseline. The actual defenses at install time are limited. &lt;code&gt;composer audit&lt;/code&gt; helps against known advisories, but not against a malicious package that was compromised minutes ago. Run it anyway. Watch community reports before you update.&lt;/p&gt;

&lt;h2&gt;
  
  
  composer audit
&lt;/h2&gt;

&lt;p&gt;Before updating, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks installed packages against the PHP Security Advisories Database. On a fresh project, nothing shows up. On an older one, you might see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Found 3 security vulnerability advisories affecting 2 packages:

Package symfony/http-kernel
CVE-2026-XXXXX: ...

Package league/commonmark
CVE-2026-XXXXX: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it before and after updating. It tells you what you are actually fixing and confirms you resolved the reported issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question Every Dependency
&lt;/h2&gt;

&lt;p&gt;Every dependency is another trust relationship. Before adding one, ask: do I need the whole package, or just one function?&lt;/p&gt;

&lt;p&gt;Not everything is replaceable. &lt;code&gt;intervention/image&lt;/code&gt; or &lt;code&gt;maatwebsite/excel&lt;/code&gt;? No. But have you actually thought about it, or did you just &lt;code&gt;composer require&lt;/code&gt; by reflex?&lt;/p&gt;

&lt;p&gt;Fewer packages means fewer attack surfaces. Fewer broken upgrades when the next Laravel major drops, too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check Social Media Before Updating
&lt;/h2&gt;

&lt;p&gt;Follow Laravel security researchers and package maintainers on social media. Major incidents surface there before formal advisories land. The Laravel-Lang attack is a good example.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Packagist Is Doing
&lt;/h2&gt;

&lt;p&gt;Composer 2.10 integrated Aikido malware detection directly into Packagist. Every release tag now gets scanned automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stable version immutability&lt;/strong&gt; is also shipping: once a version is published, it cannot be silently overwritten. One of the tricks in the Laravel-Lang attack was rewriting existing tags to inject malware, making it look like a version you already trusted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum release age&lt;/strong&gt; is coming: new releases sit in a quarantine window before they show up in &lt;code&gt;composer update&lt;/code&gt;. Security patches wait too. That's the tradeoff. But zero-day injection risk drops.&lt;/p&gt;

&lt;p&gt;The long-term plan is a two-step release flow: tag a release, get an MFA confirmation request. A stolen account alone wouldn't be enough to push a release.&lt;/p&gt;

&lt;p&gt;Good progress. The ecosystem is large, though, and none of this ships overnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Standard practice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Commit &lt;code&gt;composer.lock&lt;/code&gt;. Always.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;composer audit&lt;/code&gt; before and after updating.&lt;/li&gt;
&lt;li&gt;Update specific packages when possible, not everything at once.&lt;/li&gt;
&lt;li&gt;Question every new dependency. One class does not need a full package.&lt;/li&gt;
&lt;li&gt;Composer 2.10 added Aikido malware scanning and stable version immutability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Personal workflow additions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;jack raise-to-installed&lt;/code&gt; after every &lt;code&gt;composer update&lt;/code&gt; to pin constraints to installed versions. Not standard practice — trades upgrade convenience for narrower exposure on future unexpected jumps. Does not protect against a malicious release you just pulled.&lt;/li&gt;
&lt;li&gt;Verify no package jumped past your PHP version floor after each update.&lt;/li&gt;
&lt;li&gt;Follow Laravel security researchers on social media. Incidents surface there before formal advisories.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Supply chain attacks are no longer a JavaScript-only problem. The Laravel-Lang incident showed that PHP ecosystems are not immune. The workflow above does not guarantee safety. But it reduces the surface you're exposed to on the next update.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.aikido.dev/blog/supply-chain-attack-targets-laravel-lang-packages-with-credential-stealer" rel="noopener noreferrer"&gt;Aikido: Supply Chain Attack Targets Laravel-Lang Packages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.packagist.com/an-update-on-composer-packagist-supply-chain-security/" rel="noopener noreferrer"&gt;Packagist: Supply Chain Security Update&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.packagist.com/composer-2-10-release/" rel="noopener noreferrer"&gt;Composer 2.10 Release Notes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PHP Generics Already Exist: They're Just Hidden in PHPDoc</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Tue, 02 Jun 2026 11:26:00 +0000</pubDate>
      <link>https://dev.to/tegos/php-generics-already-exist-theyre-just-hidden-in-phpdoc-2l0c</link>
      <guid>https://dev.to/tegos/php-generics-already-exist-theyre-just-hidden-in-phpdoc-2l0c</guid>
      <description>&lt;p&gt;Every Laravel dev has written PHP generics. You just wrote them inside a comment and pretended it didn't count.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/** @return Collection&amp;lt;CartItemDTO&amp;gt; */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is a generic type. PHPStan reads it, your IDE reads it, but PHP itself shrugs and ignores it. A new RFC, &lt;a href="https://wiki.php.net/rfc/bound_erased_generic_types" rel="noopener noreferrer"&gt;Bound-Erased Generic Types&lt;/a&gt;, wants to turn those comments into real syntax. Let's start with what a generic even is, then look at why PHP has fought this for a decade.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 30-Second Theory Refresh
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;generic&lt;/strong&gt; is a type with a hole in it. Instead of writing &lt;code&gt;IntBox&lt;/code&gt;, &lt;code&gt;StringBox&lt;/code&gt;, and &lt;code&gt;UserBox&lt;/code&gt;, you write &lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt; once and decide &lt;code&gt;T&lt;/code&gt; later. The formal name is &lt;strong&gt;parametric polymorphism&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Without generics: one class per type, copy-paste forever&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IntBox&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// With generics: one class, T decided at use site&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea isn't new. Java, C#, and TypeScript all have it. PHP and JS skipped it for years, which is exactly why static analyzers grew up to fill the gap.&lt;/p&gt;

&lt;p&gt;There are two ways to ship generics. &lt;strong&gt;Reified&lt;/strong&gt; keeps the type at runtime, so &lt;code&gt;Box&amp;lt;int&amp;gt;&lt;/code&gt; and &lt;code&gt;Box&amp;lt;string&amp;gt;&lt;/code&gt; stay distinct (C#). &lt;strong&gt;Erased&lt;/strong&gt; uses it only for static analysis and throws it away before execution (Java, TypeScript). This RFC picks erasure. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It's So Hard to Add to PHP
&lt;/h2&gt;

&lt;p&gt;Generics have been on the PHP internals agenda since &lt;strong&gt;January 2014&lt;/strong&gt;. Multiple RFCs, multiple implementations, none merged. Why?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime type checks are expensive.&lt;/strong&gt; Reified generics mean PHP must store and verify type arguments on every instance. Nikita Popov built a prototype and hit superlinear type-checking cost and heavy per-instance memory. PHP is request-per-process, so you pay that on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shared spec.&lt;/strong&gt; PHPStan, Psalm, Mago, and PhpStorm have spent years guessing at the same &lt;code&gt;@template&lt;/code&gt; syntax, each a little differently. There's no official standard to follow, so an edge case that works in one tool can quietly break in another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A decade of stalls.&lt;/strong&gt; The 2016 reified RFC sat in draft for ten years. The 2024 reified continuation stalled on cross-file inference. Each attempt paid the syntax cost and shipped no syntax.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Solution: Erase, Don't Reify
&lt;/h2&gt;

&lt;p&gt;The RFC, by Seifeddine Gmati (author of the Mago analyzer), adds native syntax: &lt;code&gt;class Box&amp;lt;T&amp;gt;&lt;/code&gt;, bounds &lt;code&gt;&amp;lt;T : Animal&amp;gt;&lt;/code&gt;, defaults &lt;code&gt;&amp;lt;K = string&amp;gt;&lt;/code&gt;, variance &lt;code&gt;&amp;lt;+T&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;-T&amp;gt;&lt;/code&gt;, and an optional turbofish &lt;code&gt;::&amp;lt;…&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key word is &lt;strong&gt;bound-erased&lt;/strong&gt;. At runtime &lt;code&gt;Box&amp;lt;int&amp;gt;&lt;/code&gt; and &lt;code&gt;Box&amp;lt;string&amp;gt;&lt;/code&gt; are the same class. The type argument is erased down to its bound (or &lt;code&gt;mixed&lt;/code&gt;). Checking happens statically, like Java or TypeScript. Reified was rejected on purpose: as the RFC puts it, &lt;em&gt;"we don't need runtime type checks."&lt;/em&gt; Generics are a static-analysis tool, not a runtime one.&lt;/p&gt;

&lt;p&gt;So your comment becomes the signature, and old code keeps working. The turbofish is optional everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Today: the type parameter lives in a doc block&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * @template T
 */&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Repository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return Collection&amp;lt;T&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// With the RFC: the same intent, but the language reads it&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Repository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  It's Not New: Just Look at Your Own Code
&lt;/h2&gt;

&lt;p&gt;I grepped a Laravel 10 project I work on (PHP 8.1, Larastan level 5). The result: &lt;strong&gt;143 generic-PHPDoc annotations and 0 of my own &lt;code&gt;@template&lt;/code&gt;&lt;/strong&gt;. So the project &lt;em&gt;consumes&lt;/em&gt; generics everywhere (mostly &lt;code&gt;Collection&amp;lt;…&amp;gt;&lt;/code&gt;) but never declares them. Here's a real action from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CartIndexAction&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;Actionable&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return Collection&amp;lt;CartItemDTO&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;CartItemParamDTO&lt;/span&gt; &lt;span class="nv"&gt;$param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Collection&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cartItemService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getForUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$param&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;itemUuids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native return type is just &lt;code&gt;Collection&lt;/code&gt;. The useful part, &lt;code&gt;CartItemDTO&lt;/code&gt;, lives in a comment. Drop it and autocomplete dies and Larastan stops catching wrong-type bugs. It's worse in repositories, where you babysit the analyzer by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getUserCarts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;EloquentCollection&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @var EloquentCollection&amp;lt;Cart&amp;gt; $carts */&lt;/span&gt;
    &lt;span class="nv"&gt;$carts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$carts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;/** @var */&lt;/code&gt; exists only to feed the analyzer. And it's not just my code: Laravel itself ships generic annotations across its core classes and IDE-helper stubs. Every &lt;code&gt;Collection&lt;/code&gt; and query &lt;code&gt;Builder&lt;/code&gt; you touch is already generic in the doc blocks. The RFC authors counted &lt;a href="https://wiki.php.net/rfc/bound_erased_generic_types" rel="noopener noreferrer"&gt;200k+ files&lt;/a&gt; on GitHub using &lt;code&gt;@template&lt;/code&gt;. Adopting this RFC doesn't &lt;em&gt;introduce&lt;/em&gt; generics. They've been here for a decade. It formalizes what's already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Want This in 2026
&lt;/h2&gt;

&lt;p&gt;Heads up: the RFC is still &lt;em&gt;Under Discussion&lt;/em&gt; (v0.22), with an &lt;a href="https://github.com/php/php-src/pull/21969" rel="noopener noreferrer"&gt;open php-src PR&lt;/a&gt; that works today. The blocker isn't the code. It's a small group on internals who don't love the erased approach.&lt;/p&gt;

&lt;p&gt;We've babysat doc blocks and worked around analyzer quirks for years because generics are worth it. The implementation is ready, and the proposed syntax is already familiar to PHP developers using PHPStan and Psalm. PHP developers already write generics every day. The only question left is whether they keep living in comments, or finally become part of the language. I know which one I want in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A generic is a type with a hole: &lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt; instead of one class per type. Old idea (Java, C#, TS).&lt;/li&gt;
&lt;li&gt;PHP generics already exist in &lt;code&gt;@template&lt;/code&gt; / &lt;code&gt;&amp;lt;…&amp;gt;&lt;/code&gt; PHPDoc your analyzer reads. 200k+ files on GitHub use them.&lt;/li&gt;
&lt;li&gt;Adding them natively is hard: reified runtime checks are expensive, and ~6 analyzers disagree with no shared spec. On the agenda since 2014.&lt;/li&gt;
&lt;li&gt;The RFC picks &lt;strong&gt;bound erasure&lt;/strong&gt;: checked statically, erased at runtime, &lt;code&gt;Box&amp;lt;int&amp;gt;&lt;/code&gt; ≡ &lt;code&gt;Box&amp;lt;string&amp;gt;&lt;/code&gt;, zero runtime cost.&lt;/li&gt;
&lt;li&gt;Existing code keeps working; the turbofish &lt;code&gt;::&amp;lt;…&amp;gt;&lt;/code&gt; remains optional.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;💡 Watch Brent Roose's &lt;a href="https://www.youtube.com/watch?v=4wpW98S2xJQ" rel="noopener noreferrer"&gt;video walkthrough&lt;/a&gt; and read the &lt;a href="https://wiki.php.net/rfc/bound_erased_generic_types" rel="noopener noreferrer"&gt;RFC&lt;/a&gt; before the vote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Reincarnating a Decade-Old jQuery Project</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Sun, 17 May 2026 15:57:47 +0000</pubDate>
      <link>https://dev.to/tegos/reincarnating-a-decade-old-jquery-project-26ob</link>
      <guid>https://dev.to/tegos/reincarnating-a-decade-old-jquery-project-26ob</guid>
      <description>&lt;p&gt;Cleaning my old drive, I found &lt;code&gt;burger.zip&lt;/code&gt; dated 2015. Inside: one of my first paid web gigs, a burger builder for a Swiss client. &lt;br&gt;
Radial menu of ingredients, click one, it flies onto a bun, price ticks up in CHF. jQuery 1.7. I had to give it a second life.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before (2015, jQuery)&lt;/th&gt;
&lt;th&gt;After (2026, TS + GSAP)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F35zkji711wvoof2dwrpx.gif" alt="legacy" width="600" height="400"&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faf377cogz06lhkevi8sc.gif" alt="modern" width="600" height="400"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  What Happened
&lt;/h2&gt;

&lt;p&gt;The original was 1244 lines across five files. jQuery, a 780-line &lt;code&gt;radmenu&lt;/code&gt; plugin, hardcoded math, and this stack-height fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;switch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hhh&lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hhh&lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hhh&lt;/span&gt;&lt;span class="o"&gt;-=&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hhh&lt;/span&gt;&lt;span class="o"&gt;-=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hhh&lt;/span&gt;&lt;span class="o"&gt;-=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hhh&lt;/span&gt;&lt;span class="o"&gt;-=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#burger_wrap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#burger_wrap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;height&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;hhh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes. Past me solved stack height with a hardcoded switch.&lt;/p&gt;

&lt;p&gt;The code still worked. Three CDN scripts loaded over &lt;code&gt;http://&lt;/code&gt;, which modern browsers now block. &lt;br&gt;
One &lt;code&gt;s&lt;/code&gt; made it bootable again. I committed that fix as &lt;code&gt;legacy/&lt;/code&gt;, untouched otherwise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Investigation
&lt;/h2&gt;

&lt;p&gt;Then I rebuilt it next door at &lt;code&gt;modern/&lt;/code&gt;. Vite for the build, TypeScript for safety, GSAP for the fly animation. Same UX: ingredients orbit the center, click flies them onto the bun, click in the stack removes them.&lt;/p&gt;

&lt;p&gt;The radial picker took 50 lines of trig instead of a 780-line jQuery plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;els&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;els&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translate(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stack-height switch became flex column with negative margin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#stack&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;#stack&lt;/span&gt; &lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-44px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Making It Feel Real
&lt;/h2&gt;

&lt;p&gt;The first rebuild worked, but it felt flat. I ran three parallel agents: one on bun art, one on ingredients, one on motion. &lt;br&gt;
Result: gradients and sesame seeds on the buns, grill marks on the beef, drip teardrops on the cheese. &lt;br&gt;
Plus a quarter-turn tumble with motion blur on each fly-in, a squash on landing, and a slow idle jiggle once the stack hits three items. &lt;br&gt;
CSS &lt;code&gt;perspective&lt;/code&gt; + &lt;code&gt;rotateX(8deg)&lt;/code&gt; tilts the burger toward the viewer.&lt;/p&gt;

&lt;p&gt;Two things bit me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GSAP overwrites CSS transforms.&lt;/strong&gt; I centered the burger with &lt;code&gt;transform: translateY(-50%)&lt;/code&gt;. The first &lt;code&gt;gsap.to(burgerEl, { y: 3 })&lt;/code&gt; stomped that translate and the burger drifted off the page. &lt;br&gt;
Fix: center without &lt;code&gt;transform&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#burger&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;max-content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Agents can polish away meaning.&lt;/strong&gt; My bun-art agent rewrote &lt;code&gt;down.svg&lt;/code&gt; with cleaner gradients and richer seeds. &lt;br&gt;
It looked sharper. Flat. The original encoded a side-cut profile: dark crust on top, cream crumb in the middle, brown base. The rewrite lost it. &lt;br&gt;
I reverted to the legacy SVG verbatim and kept the new top bun. The legacy SVG encoded something the polish lost, and no diff would have told me.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Archived the 2015 original verbatim under &lt;code&gt;legacy/&lt;/code&gt;, rebuilt next door in TypeScript + GSAP&lt;/li&gt;
&lt;li&gt;780-line &lt;code&gt;jQuery.radmenu&lt;/code&gt; plugin replaced with ~50 lines of trig&lt;/li&gt;
&lt;li&gt;Three parallel agents added bun gradients, ingredient detail, tumble + squash + idle jiggle&lt;/li&gt;
&lt;li&gt;GSAP &lt;code&gt;y&lt;/code&gt; tweens overwrite CSS &lt;code&gt;transform: translateY(-50%)&lt;/code&gt; — center without &lt;code&gt;transform&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;AI polish stripped the side-cut anatomy from the bottom-bun SVG; reverted to the 2015 hand-drawn art&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/tegos/burger-reborn" rel="noopener noreferrer"&gt;https://github.com/tegos/burger-reborn&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laravel, after the happy path.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>typescript</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Why telescope:clear Is Slow and How to Reclaim Disk in Seconds</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Wed, 29 Apr 2026 15:25:28 +0000</pubDate>
      <link>https://dev.to/tegos/why-telescopeclear-is-slow-and-how-to-reclaim-disk-in-seconds-26of</link>
      <guid>https://dev.to/tegos/why-telescopeclear-is-slow-and-how-to-reclaim-disk-in-seconds-26of</guid>
      <description>&lt;p&gt;A while back I wrote about &lt;a href="https://dev.to/tegos/efficiently-managing-telescope-entries-with-laravel-telescope-flusher-484a"&gt;&lt;code&gt;laravel-telescope-flusher&lt;/code&gt;&lt;/a&gt; - a tiny package I built to wipe Telescope data without waiting forever. It just hit &lt;strong&gt;1,000 installs&lt;/strong&gt; on Packagist 🎉, so it felt like the right time to actually back up the original post with real numbers, not just claims.&lt;/p&gt;

&lt;p&gt;So I sat down, seeded a million Telescope entries on a fresh MySQL 8.0, and timed the three things you'd reach for: &lt;code&gt;telescope:clear&lt;/code&gt;, &lt;code&gt;telescope:prune&lt;/code&gt;, and &lt;code&gt;telescope:flush&lt;/code&gt;. Spoiler: the gap is bigger than I expected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxga947ukbh603dv3hpx8.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxga947ukbh603dv3hpx8.gif" alt="Telescope flush vs clear demo" width="760" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick recap of why &lt;code&gt;telescope:clear&lt;/code&gt; is slow
&lt;/h2&gt;

&lt;p&gt;Two things kill it. First, the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vendor/laravel/telescope/src/Storage/DatabaseEntriesRepository.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'telescope_entries'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$deleted&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...same for telescope_monitoring&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$chunkSize = 1000&lt;/code&gt;. A million rows = a thousand round-trip &lt;code&gt;DELETE&lt;/code&gt; statements, each writing to redo log, undo log, double-write buffer.&lt;/p&gt;

&lt;p&gt;Second (and this one I missed in the original post): &lt;code&gt;telescope_entries_tags&lt;/code&gt; has a foreign key on &lt;code&gt;entry_uuid&lt;/code&gt; with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;. With ~3 tags per entry, every parent delete triggers a cascade delete on the tag table. On a million entries, that's &lt;strong&gt;3 million extra deletes&lt;/strong&gt; the loop never asked for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;telescope:prune --hours=24&lt;/code&gt; is the same loop with a &lt;code&gt;WHERE&lt;/code&gt; filter. Same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  And &lt;code&gt;DELETE&lt;/code&gt; doesn't give you the disk back
&lt;/h2&gt;

&lt;p&gt;I missed this on the first pass. After &lt;code&gt;telescope:clear&lt;/code&gt; finishes, &lt;code&gt;information_schema.tables&lt;/code&gt; reports the data length as basically zero. Looks done. Then check the actual file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lah&lt;/span&gt; /var/lib/mysql/telescope_test/telescope_&lt;span class="k"&gt;*&lt;/span&gt;.ibd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.ibd&lt;/code&gt; files are still huge. InnoDB &lt;strong&gt;doesn't return space to the OS after &lt;code&gt;DELETE&lt;/code&gt;&lt;/strong&gt; - it only marks pages reusable for future inserts. To actually shrink the file you need &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; (which rebuilds it) or &lt;code&gt;ALTER TABLE ... ENGINE=InnoDB&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;telescope:clear&lt;/code&gt; does neither. So your dev disk stays full.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;Setup: MySQL 8.0 in Docker, default config. Seed: &lt;strong&gt;1,000,000&lt;/strong&gt; &lt;code&gt;telescope_entries&lt;/code&gt; (~2 KB JSON content each), &lt;strong&gt;3,000,000&lt;/strong&gt; rows in &lt;code&gt;telescope_entries_tags&lt;/code&gt;, real foreign key with cascade. Bench script lives in &lt;a href="https://github.com/tegos/laravel-telescope-flusher/tree/main/bench" rel="noopener noreferrer"&gt;&lt;code&gt;bench/&lt;/code&gt;&lt;/a&gt; - go run it yourself.&lt;/p&gt;

&lt;p&gt;Starting state, identical for both runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;telescope_entries          rows=1000000   logical=2.33 GB   .ibd=2.4 GB
telescope_entries_tags     rows=3000000   logical=672 MB    .ibd=688 MB
telescope_monitoring       rows=50        logical=16 KB     .ibd=112 KB
TOTAL                      rows=4000050   logical=2.99 GB   .ibd=3.1 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;&lt;code&gt;telescope:clear&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;telescope:flush&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wall time&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;9025 s&lt;/strong&gt; (≈150 min)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.21 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logical size after&lt;/td&gt;
&lt;td&gt;128 KB&lt;/td&gt;
&lt;td&gt;128 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.ibd&lt;/code&gt; files on disk after&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.1 GB&lt;/strong&gt; (unchanged)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;428 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly &lt;strong&gt;7400× faster&lt;/strong&gt; and 3 GB of disk you actually get back. Both runs leave &lt;code&gt;info_schema&lt;/code&gt; reporting the same size, by the way. That's the trap. Only &lt;code&gt;ls -lah&lt;/code&gt; on the &lt;code&gt;.ibd&lt;/code&gt; files tells you the truth.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prune --hours=0&lt;/code&gt; benches almost identically to &lt;code&gt;clear&lt;/code&gt; (same loop, same FK cascade), so I didn't bother running it to completion. The shape of the result is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;flush&lt;/code&gt; does differently
&lt;/h2&gt;

&lt;p&gt;The package's whole command is short enough to paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getSchemaBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withoutForeignKeyConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'telescope_entries'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'telescope_entries_tags'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'telescope_monitoring'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getDriverName&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OPTIMIZE TABLE telescope_entries'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No magic. &lt;code&gt;TRUNCATE&lt;/code&gt; is a metadata operation. Instant on InnoDB, no per-row work, no cascade. The &lt;code&gt;withoutForeignKeyConstraints&lt;/code&gt; wrapper is needed because &lt;code&gt;TRUNCATE&lt;/code&gt; doesn't fire cascades, so you have to disable the FK check yourself. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; rebuilds the table on &lt;code&gt;innodb_file_per_table&lt;/code&gt; (the default for years) and produces a fresh, tiny &lt;code&gt;.ibd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's also an &lt;code&gt;App::isLocal()&lt;/code&gt; guard - &lt;code&gt;TRUNCATE&lt;/code&gt; is irreversible, you really don't want to fat-finger this anywhere except dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use what
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;telescope:clear&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default local cleanup. Works, slow on big tables, leaves disk allocated.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;telescope:prune --hours=24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scheduled retention - keep last N hours. Same disk problem, but table size stays bounded over time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;telescope:flush&lt;/code&gt; (package)&lt;/td&gt;
&lt;td&gt;Dev nuke. Telescope ballooned, you want it gone in a second and the disk back.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I don't run Telescope in production, neither should you, so the local-only guard isn't a limitation.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;telescope:clear&lt;/code&gt; = chunked &lt;code&gt;DELETE LIMIT 1000&lt;/code&gt; + cascading FK on tags. On 1M entries: &lt;strong&gt;2.5 hours&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;InnoDB &lt;strong&gt;doesn't shrink the &lt;code&gt;.ibd&lt;/code&gt; after &lt;code&gt;DELETE&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;info_schema&lt;/code&gt; lies, &lt;code&gt;ls -lah&lt;/code&gt; doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;telescope:flush&lt;/code&gt; = &lt;code&gt;TRUNCATE&lt;/code&gt; + &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt;. &lt;strong&gt;1.21 s&lt;/strong&gt; on the same data, 3 GB → 428 KB on disk.&lt;/li&gt;
&lt;li&gt;If your &lt;code&gt;info_schema&lt;/code&gt; says the table is empty but &lt;code&gt;df&lt;/code&gt; disagrees, it's the InnoDB pages, not your imagination.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;👉 &lt;a href="https://github.com/tegos/laravel-telescope-flusher" rel="noopener noreferrer"&gt;Package on GitHub&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://packagist.org/packages/tegos/laravel-telescope-flusher" rel="noopener noreferrer"&gt;Package on Packagist&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tegos/efficiently-managing-telescope-entries-with-laravel-telescope-flusher-484a"&gt;Original post (the "why" without numbers)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.mysql.com/doc/refman/8.0/en/optimize-table.html" rel="noopener noreferrer"&gt;MySQL docs: &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notes from real-world Laravel.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>mysql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Laravel whereDate() Silently Kills Your Index</title>
      <dc:creator>Ivan Mykhavko</dc:creator>
      <pubDate>Thu, 23 Apr 2026 14:36:27 +0000</pubDate>
      <link>https://dev.to/tegos/laravel-wheredate-silently-kills-your-index-2lnf</link>
      <guid>https://dev.to/tegos/laravel-wheredate-silently-kills-your-index-2lnf</guid>
      <description>&lt;p&gt;&lt;code&gt;whereDate('created_at', $date)&lt;/code&gt; looks clean, but on a big table it quietly drops your index and does a full scan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Say you want notifications created on a specific day. The obvious call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;UserNotification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-04-23'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laravel generates this SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_notifications&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-04-23'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See &lt;code&gt;DATE(created_at)&lt;/code&gt;? MySQL has to compute that function for every row before comparing. Your &lt;code&gt;created_at&lt;/code&gt; index is useless, &lt;code&gt;EXPLAIN&lt;/code&gt; shows a full table scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----+-------------+--------------------+------+---------+
| id | select_type | table              | type | rows    |
+----+-------------+--------------------+------+---------+
|  1 | SIMPLE      | user_notifications | ALL  | 5000000 |
+----+-------------+--------------------+------+---------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 5k rows you won't notice. On 5M rows you will.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;Compare the column directly against a range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-04-23'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;startOfDay&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addDay&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;UserNotification&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the SQL looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_notifications&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-04-23 00:00:00'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="s1"&gt;'2026-04-24 00:00:00'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The column is untouched. MySQL can do a clean range scan on the &lt;code&gt;created_at&lt;/code&gt; index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----+-------------+--------------------+-------+------+
| id | select_type | table              | type  | rows |
+----+-------------+--------------------+-------+------+
|  1 | SIMPLE      | user_notifications | range | 1200 |
+----+-------------+--------------------+-------+------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Half-open range (&lt;code&gt;&amp;gt;=&lt;/code&gt; start, &lt;code&gt;&amp;lt;&lt;/code&gt; next day) is the safer form - &lt;code&gt;endOfDay()&lt;/code&gt; ends at &lt;code&gt;23:59:59.999999&lt;/code&gt;, and comparing against &lt;code&gt;23:59:59&lt;/code&gt; can quietly miss the last second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Works
&lt;/h2&gt;

&lt;p&gt;This is called &lt;strong&gt;sargability&lt;/strong&gt; - "Search ARGument ABLE". A predicate is sargable when the column appears as-is, without a function wrapping it. The moment you write &lt;code&gt;DATE(col)&lt;/code&gt;, &lt;code&gt;YEAR(col)&lt;/code&gt;, or &lt;code&gt;LOWER(col)&lt;/code&gt;, the optimizer can't use a standard B-tree index on &lt;code&gt;col&lt;/code&gt; anymore.&lt;/p&gt;

&lt;p&gt;The same trap applies to &lt;code&gt;whereDay()&lt;/code&gt;, &lt;code&gt;whereMonth()&lt;/code&gt;, &lt;code&gt;whereYear()&lt;/code&gt;, and &lt;code&gt;whereTime()&lt;/code&gt; all of them wrap the column in a MySQL function. Fine on small lookup tables. Painful on any growing log-style table.&lt;/p&gt;

&lt;p&gt;Heads up: PostgreSQL lets you build a functional index (&lt;code&gt;CREATE INDEX ON t ((date(created_at)))&lt;/code&gt;), so there &lt;code&gt;whereDate()&lt;/code&gt; can still hit an index. MySQL has no real equivalent for this case, generated columns with an index work, but they're extra schema baggage for something a range filter already solves.&lt;/p&gt;

&lt;p&gt;Tutorials love &lt;code&gt;whereDate('created_at', today())&lt;/code&gt;. I still prefer the range form. It reads the same everywhere, and I never have to wonder whether an index will be used.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;whereDate()&lt;/code&gt; is convenient but non-sargable on large tables it forces a full scan. Compare &lt;code&gt;created_at&lt;/code&gt; against a half-open &lt;code&gt;&amp;gt;= startOfDay()&lt;/code&gt; / &lt;code&gt;&amp;lt; startOfDay() + 1 day&lt;/code&gt; range and keep the index in play.&lt;/p&gt;

&lt;p&gt;💡 Same story for &lt;code&gt;whereMonth()&lt;/code&gt;, &lt;code&gt;whereYear()&lt;/code&gt;, &lt;code&gt;whereDay()&lt;/code&gt;, &lt;code&gt;whereTime()&lt;/code&gt;. If the column is wrapped in a function, assume the index is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Author's Note
&lt;/h2&gt;

&lt;p&gt;Thanks for sticking around!&lt;br&gt;
Find me on &lt;a href="https://dev.to/tegos"&gt;dev.to&lt;/a&gt;, &lt;a href="https://www.linkedin.com/in/ivan-mykhavko/" rel="noopener noreferrer"&gt;linkedin&lt;/a&gt;, or you can check out my work on &lt;a href="https://github.com/tegos" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notes from real-world Laravel.&lt;/strong&gt;&lt;/p&gt;

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