<?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: UCodeSoft</title>
    <description>The latest articles on DEV Community by UCodeSoft (@ucodesoft_0ffeef866).</description>
    <link>https://dev.to/ucodesoft_0ffeef866</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%2F4028174%2Fb5379ead-e4d5-4be6-99b5-eb4c76f49a48.png</url>
      <title>DEV Community: UCodeSoft</title>
      <link>https://dev.to/ucodesoft_0ffeef866</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ucodesoft_0ffeef866"/>
    <language>en</language>
    <item>
      <title>Caching in Laravel: What Actually Holds Up Once Real Traffic Hits It</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:43:27 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/caching-in-laravel-what-actually-holds-up-once-real-traffic-hits-it-3fin</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/caching-in-laravel-what-actually-holds-up-once-real-traffic-hits-it-3fin</guid>
      <description>&lt;p&gt;"Just add Redis" solves the easy part of caching, and it solves it fast. Wrap a query in &lt;code&gt;Cache::remember()&lt;/code&gt;, watch response times drop, ship it. The problem is that's maybe 80% of the work, and the remaining 20%, stale data, stampedes, invalidation bugs that only show up under real concurrency, ends up eating more engineering time than the original slow query ever cost.&lt;/p&gt;

&lt;p&gt;We've shipped caching layers that held up fine in staging and fell over in production more than once, and every time, the cause was one of a small handful of patterns repeating itself. Here's what we've actually learned, the parts that bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few terms first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cache-aside&lt;/strong&gt;, the default pattern most Laravel apps should reach for first. Check the cache, on a miss read from the database, and populate the cache. &lt;code&gt;Cache::remember()&lt;/code&gt; implements this in one call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write-through&lt;/strong&gt;, writes go to the cache and the database at the same time, synchronously. Reads stay fresh, writes get a little slower.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache stampede&lt;/strong&gt;, what happens when a popular key expires, and a large number of requests hit the miss at the same moment. Without protection, all of them fall through to the database at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTL&lt;/strong&gt;, how long a cached value lives before it's considered stale. Simple to reason about, but it's a guess about how long data stays close enough to correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tagged invalidation&lt;/strong&gt;, grouping related cache entries under a shared label so you can clear all of them in one call. Only works on Redis and Memcached, not &lt;code&gt;array&lt;/code&gt; or &lt;code&gt;file&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that should be your default
&lt;/h2&gt;

&lt;p&gt;For most reads, cache-aside via &lt;code&gt;Cache::remember()&lt;/code&gt; is the right starting point, not the fanciest option, but the resilient one. Cache unreachable, cold, or empty, the app falls back to the database and keeps working, just slower.&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\Cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getUser&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;?User&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;addMinutes&lt;/span&gt;&lt;span class="p"&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;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&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="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 single call does the whole cache-aside dance: check cache, fall through on miss, store the result. Reach for this first on nearly every project, only move past it with a concrete reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stampede problem nobody notices until it's a page at 3 am
&lt;/h2&gt;

&lt;p&gt;A popular key expires. If it backs something with real traffic, dozens or thousands of requests can land on that exact miss window simultaneously. Every one falls through to the database and runs the same query at once, right when the database was least prepared for a burst.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh64sqadilokvypx9n3ef.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh64sqadilokvypx9n3ef.png" alt="What happens when a hot key expires" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is a lock around the cache-population step, so only the first request through the door actually queries the database:&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\Cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getUserSafe&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;?User&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cached&lt;/span&gt; &lt;span class="o"&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;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="s2"&gt;"lock:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Re-check inside the lock; someone else may have populated it already&lt;/span&gt;
        &lt;span class="k"&gt;return&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;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;addMinutes&lt;/span&gt;&lt;span class="p"&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;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&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="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;Needs a driver with atomic lock support: Redis, Memcached, DynamoDB, &lt;code&gt;file&lt;/code&gt; and &lt;code&gt;database&lt;/code&gt; won't work here. &lt;code&gt;block()&lt;/code&gt; gives waiting requests a few seconds to pick up the freshly-cached value instead of failing outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invalidation is where most caching bugs actually live
&lt;/h2&gt;

&lt;p&gt;The most common caching bug we see: someone updates a record, forgets to invalidate the cached version, the app serves stale data with no error, nothing that looks broken until a user notices.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobq1tvyszxyncreb4y8m.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobq1tvyszxyncreb4y8m.png" alt="Two ways a cache gets invalidated" width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Manual invalidation works, until it doesn't:&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;function&lt;/span&gt; &lt;span class="n"&gt;updateUser&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;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;User&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;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&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;$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="nv"&gt;$data&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;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&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;addMinutes&lt;/span&gt;&lt;span class="p"&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;return&lt;/span&gt; &lt;span class="nv"&gt;$user&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;Fine for this one path. Problem is the model probably gets updated from more than one place eventually: an admin panel, a background job, an import script, and everyone needs to remember this line exists. Miss one, quiet bug.&lt;/p&gt;

&lt;p&gt;Hook invalidation into model events instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserObserver&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;updated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&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="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&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;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&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="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&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;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register once, &lt;code&gt;User::observe(UserObserver::class)&lt;/code&gt;, invalidation stops being something a developer has to remember; it's structurally tied to the model itself, not any one call site.&lt;/p&gt;

&lt;p&gt;For one change, clearing several related entries at once, tags handle it without tracking individual keys:&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;// Writing with tags&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;tags&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:profile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Invalidate everything tagged 'users' in one call, e.g. after a bulk import&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;tags&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'users'&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;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tags only work on Redis and Memcached. On &lt;code&gt;array&lt;/code&gt; or &lt;code&gt;file&lt;/code&gt; this silently isn't doing what you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfall that slips past even careful developers
&lt;/h2&gt;

&lt;p&gt;Caching a query result that itself triggers lazy-loaded relationships doesn't solve your N+1 problem; it relocates it into the cache-population code. The cache entry ends up correct, but building it was just as expensive as never caching; you've only saved the cost on requests after the first.&lt;/p&gt;

&lt;p&gt;Eager-load before you cache, always.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ugbtpp2kr6uva9jijoo.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ugbtpp2kr6uva9jijoo.png" alt="The pitfalls that actually show up in production" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cache-aside via &lt;code&gt;Cache::remember()&lt;/code&gt; is the right default for most reads, resilient to cache failures by design.&lt;/li&gt;
&lt;li&gt;Stampede protection matters the moment a cached key backs anything with real concurrent traffic; &lt;code&gt;Cache::lock()&lt;/code&gt; is a small addition with an outsized payoff.&lt;/li&gt;
&lt;li&gt;Prefer model observers over manual &lt;code&gt;Cache::forget()&lt;/code&gt; calls scattered through the codebase; invalidation should be structurally hard to forget, not a discipline you're hoping every developer maintains.&lt;/li&gt;
&lt;li&gt;Caching a lazy-loaded relationship just moves your N+1 problem into the cache-population step; eager-load first.&lt;/li&gt;
&lt;li&gt;Never cache without a TTL, and never cache a failure or empty response; both turn caching from a performance win into an active bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Caching isn't a performance hack bolted on at the end; it's a data consistency problem with a performance benefit attached. Treat it that way and most of these pitfalls stop happening. &lt;/p&gt;

&lt;p&gt;Full write-up with more context is on our Substack: &lt;a href="https://ucodesoft.substack.com/p/caching-in-laravel-what-actually" rel="noopener noreferrer"&gt;https://ucodesoft.substack.com/p/caching-in-laravel-what-actually&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>redis</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Repository Pattern in Laravel, When It's Worth It and When It's Just Extra Folders</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Tue, 04 Aug 2026 04:23:42 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/the-repository-pattern-in-laravel-when-its-worth-it-and-when-its-just-extra-folders-mf</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/the-repository-pattern-in-laravel-when-its-worth-it-and-when-its-just-extra-folders-mf</guid>
      <description>&lt;p&gt;Eloquent is genuinely good, expressive enough that you write a working query in one line and move on with your day. Which is exactly why the repository pattern question comes up so often: if Eloquent already reads this cleanly inside a controller, why wrap it in another layer?&lt;/p&gt;

&lt;p&gt;We've built Laravel apps both ways, with a repository layer and without. Here's what it's actually solving, so you can tell when you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few terms first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The repository pattern&lt;/strong&gt;, a class between your controllers and Eloquent models, whose only job is fetching and storing data. The controller asks the repository, never talks to Eloquent directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An interface&lt;/strong&gt;, a contract, a list of methods a class promises to implement without saying how. &lt;code&gt;UserRepositoryInterface&lt;/code&gt; says "something implementing me has a &lt;code&gt;find()&lt;/code&gt; method," not whether that something hits MySQL, Redis, or an API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependency injection&lt;/strong&gt;, a class receives what it needs through its constructor instead of creating it itself. A controller taking &lt;code&gt;UserRepositoryInterface $repository&lt;/code&gt; doesn't know or care which concrete class Laravel hands it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service container binding&lt;/strong&gt;, how Laravel decides which concrete class to hand over when something asks for an interface. Bind once, usually in a service provider; every subsequent injection works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A service&lt;/strong&gt;, distinct from a repository, holds actual business logic, the workflow, not just data access. This distinction trips people up more than anything else here.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxu9gh2pi1zqus3b3p7gr.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxu9gh2pi1zqus3b3p7gr.png" alt="Where the repository actually sits" width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's actually solving
&lt;/h2&gt;

&lt;p&gt;Not code organization for its own sake; it's about what happens when the same query needs to change, and it's been copy-pasted into five controllers.&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;index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderBy&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;'desc'&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;paginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users.index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&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;Fine on its own. But &lt;code&gt;User::where('status', 1)-&amp;gt;latest()-&amp;gt;get()&lt;/code&gt; shows up in more than one place once an app grows, and when the business rule changes- active users now also need a verified email, say, you're hunting down every place that query got duplicated. Miss one, you've got a bug that only shows up in production.&lt;/p&gt;

&lt;p&gt;A repository turns that into one method everything else 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;activeUsers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;-&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;'email_verified_at'&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="kc"&gt;null&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;latest&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change it once, and every controller calling &lt;code&gt;$this-&amp;gt;userRepository-&amp;gt;activeUsers()&lt;/code&gt; picks up the new behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting one up
&lt;/h2&gt;

&lt;p&gt;Interface first, the contract every implementation honors:&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="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;UserRepositoryInterface&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;find&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&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;update&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="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&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;delete&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;The class that implements 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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;UserRepositoryInterface&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="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;find&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&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;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&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;Bind the interface to the implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;UserRepositoryInterface&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="nc"&gt;UserRepository&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;Inject it wherever 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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;UserRepositoryInterface&lt;/span&gt; &lt;span class="nv"&gt;$repository&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="n"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$repository&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 controller genuinely doesn't know or care whether &lt;code&gt;find()&lt;/code&gt; hits MySQL, reads from a cache, or calls another service. That's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caching example is where it actually clicks
&lt;/h2&gt;

&lt;p&gt;Testing is the usual pitch, and it's real, but caching lands better in practice. Decide to cache user lookups, and without a repository you're editing every place &lt;code&gt;User::findOrFail()&lt;/code&gt; gets called. With one, you change exactly one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;find&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;span class="k"&gt;return&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;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;"user_&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="mi"&gt;600&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every controller calling &lt;code&gt;$this-&amp;gt;userRepository-&amp;gt;find($id)&lt;/code&gt; is now cached; none of them changed. A decision that used to touch a dozen files now touches one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we see this actually go wrong
&lt;/h2&gt;

&lt;p&gt;Not skipping repositories, blurring the line between a repository and a service.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvj96bcxouw228cizy1hf.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvj96bcxouw228cizy1hf.png" alt="Repository and Service aren't the same job" width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A repository fetches and stores data, full stop. A service orchestrates a workflow, calling several repositories, sending emails, firing events, whatever the process needs.&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;// Repository: data access&lt;/span&gt;
&lt;span class="nv"&gt;$productRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;find&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="c1"&gt;// Service: the actual workflow&lt;/span&gt;
&lt;span class="nv"&gt;$orderService&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;placeOrder&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write business logic inside a repository, validate input there, inject &lt;code&gt;Request&lt;/code&gt;, return an HTTP response, and it's turned into something else wearing a repository's name. It still compiles; it just stops meaning anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to actually reach for this
&lt;/h2&gt;

&lt;p&gt;Not automatic on every project, and shouldn't be.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbfodwor9valspom1wifd.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbfodwor9valspom1wifd.png" alt="When it's worth the extra layer, and when it isn't" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Earns its place on medium to large applications, more than one developer touching the same models, a query genuinely reused across controllers, automated tests actually part of the plan, or a real possibility of swapping the data source down the line.&lt;/p&gt;

&lt;p&gt;Usually not worth it on a small CRUD app with one or two developers, a query used in exactly one place, or where Eloquent's own expressiveness already gets you there. Adding this structure to a small project purely because it's "best practice" just adds files to navigate for the same functionality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repositories earn their place when a query is reused, a team is more than one person, or swapping data sources is a real possibility, not by default on every project.&lt;/li&gt;
&lt;li&gt;The caching use case makes the value obvious faster than the testing pitch does; a change that used to touch a dozen controllers touches one method instead.&lt;/li&gt;
&lt;li&gt;Keep repositories and services separate. Repository fetches and stores. Service orchestrates. Blur that line and both stop meaning anything.&lt;/li&gt;
&lt;li&gt;Interfaces plus dependency injection are what make a repository swappable; without the interface you've just moved the query, not decoupled anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full writeup with more of the reasoning is on our Substack: &lt;a href="https://ucodesoft.substack.com/p/the-repository-pattern-in-laravel" rel="noopener noreferrer"&gt;https://ucodesoft.substack.com/p/the-repository-pattern-in-laravel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>designpatterns</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why Timely Upgrades Matter, a Laravel and Backpack Story</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Tue, 28 Jul 2026 19:34:50 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/why-timely-upgrades-matter-a-laravel-and-backpack-story-29o9</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/why-timely-upgrades-matter-a-laravel-and-backpack-story-29o9</guid>
      <description>&lt;p&gt;"If it's working, don't touch it." We've all heard some version of that advice, and it's tempting to actually follow it. Why go anywhere near a stable application when everything's already fine?&lt;/p&gt;

&lt;p&gt;We took on a project a while back that changed our answer. A Laravel admin panel built on Backpack, and it reminded us the real risk was never upgrading. The real risk is waiting so long that upgrading stops being an option.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few terms first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A major version bump&lt;/strong&gt;, an upgrade where the maintainers are telling you, explicitly, things might break. Patch releases are safe, major releases come with a list of things that changed on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A breaking change&lt;/strong&gt;, something that worked one way in the old version and works differently, or not at all, in the new one. Deprecated methods, renamed classes, changed signatures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An incremental upgrade path&lt;/strong&gt;, moving through major versions one at a time instead of jumping straight to the target. Slower on paper, much faster once something breaks and you need to know why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regression testing&lt;/strong&gt;, checking that everything that used to work still works, after a change that wasn't supposed to touch it. Not new feature testing, making sure you didn't quietly break something three steps removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical debt&lt;/strong&gt;, the growing gap between the version you're running and the version that's actually supported. Costs nothing the day you skip an upgrade. Costs a lot more years later, when skipping stops being an option.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmjgvmgyfnpr6d0zayndw.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmjgvmgyfnpr6d0zayndw.png" alt="The shortcut we didn't take" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this one started
&lt;/h2&gt;

&lt;p&gt;The application was on Laravel 8, Backpack 4, an older PHP version. Nothing broken, just old. The client wanted everything current, Laravel 12, Backpack 7, PHP 8.2+.&lt;/p&gt;

&lt;p&gt;The tempting move: point composer at the newest versions, run the update, fix whatever complains. Laravel 8 to 12 in one shot, Backpack 4 to 7 in one shot, done. Except it's not done, it's deferred to whenever things start breaking, and by then you're debugging four major versions of change at once with no idea which one caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we didn't take the shortcut
&lt;/h2&gt;

&lt;p&gt;Lesson learned the hard way over the years: never jump multiple major versions in one move if you can help it. Every Laravel release carries its own breaking changes. Every Backpack release improves the admin panel, but also deprecates APIs, bumps dependencies, sometimes restructures how packages are organized.&lt;/p&gt;

&lt;p&gt;Skip straight from Backpack 4 to 7 and the moment something breaks, you're stuck. Was that Laravel? Backpack? The PHP version change underneath both? Three major changes landing at once turns root-causing into guesswork.&lt;/p&gt;

&lt;p&gt;So we followed the official upgrade path instead, one version at a time, testing at every stop.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7x019p2qjkb9rsualvk.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7x019p2qjkb9rsualvk.png" alt="Three phases, each one a stable stopping point" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase one: Laravel 8 to 9, Backpack 4 to 5
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require backpack/crud:^5.0
composer update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Republished Backpack's assets after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan vendor:publish &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Backpack&lt;/span&gt;&lt;span class="se"&gt;\C&lt;/span&gt;&lt;span class="s2"&gt;RUD&lt;/span&gt;&lt;span class="se"&gt;\B&lt;/span&gt;&lt;span class="s2"&gt;ackpackServiceProvider"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, before touching anything else, went through every CRUD screen, relationship field, filter, permission, widget, and custom operation by hand. Only once that was clean did we move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase two: Laravel 9 to 10, Backpack 5 to 6
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require backpack/crud:^6.0
composer update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less about surviving the upgrade, more about improving the code while we were already in there. Refactored controllers that had gotten messy, tightened dependency injection, updated CRUD operations to current conventions, ran the same testing pass again.&lt;/p&gt;

&lt;p&gt;Each upgrade wasn't just a version bump, it was a real chance to clean up cruft that had been quietly accumulating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase three: Laravel 10 to 12, Backpack 6 to 7, PHP to 8.2+
&lt;/h2&gt;

&lt;p&gt;Biggest jump, most structural change. Backpack 7 went modular on a lot of optional fields, rich text editors aren't bundled by default anymore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require backpack/tinymce-field
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require backpack/ckeditor-field
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;File uploads needed their own step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan backpack:upgrade-dropzone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backpack ships an upgrade helper for a chunk of the mechanical changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan backpack:upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never treated automated helper output as the finish line. After every automated step, went back through the code by hand and re-ran the full feature test again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually mattered more than the upgrades themselves
&lt;/h2&gt;

&lt;p&gt;The workflow stayed the same across all three phases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer update
php artisan optimize:clear
php artisan migrate
php artisan route:list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco7tfyhyqz4su5kpdww2.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco7tfyhyqz4su5kpdww2.png" alt="What actually got checked after every version bump" width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then, every time: CRUD operations, relationship fields, upload fields, custom operations, filters, widgets, authentication, permissions. Not a skim, an actual walkthrough of each one. Only after that, the next major version. The upgrades themselves were mostly mechanical. The testing discipline is what kept any of it safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Never skip major versions in one jump if you can avoid it, isolating a breaking change across four version bumps at once is close to guesswork.&lt;/li&gt;
&lt;li&gt;Automated upgrade helpers save time on mechanical changes, they're not a substitute for manually re-testing every feature.&lt;/li&gt;
&lt;li&gt;Test the same full checklist after every phase, not just once at the end, regressions introduced in phase one can hide until phase three.&lt;/li&gt;
&lt;li&gt;Technical debt is invisible day to day and expensive all at once, the longer an upgrade waits, the more versions stack up between where you are and where you need to be.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't be afraid of the upgrade, be more afraid of the day the application's fallen so far behind that upgrading feels impossible. One version at a time, test everything, keep the client in the loop. Full writeup with more context is on our Substack: &lt;a href="https://ucodesoft.substack.com/p/why-timely-upgrades-matter-a-laravel" rel="noopener noreferrer"&gt;https://ucodesoft.substack.com/p/why-timely-upgrades-matter-a-laravel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>backpack</category>
      <category>softwaremaintenance</category>
    </item>
    <item>
      <title>Upgrading Legacy Laravel and Moving Millions of Rows Without Taking the Site Down</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Tue, 28 Jul 2026 03:41:00 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/upgrading-legacy-laravel-and-moving-millions-of-rows-without-taking-the-site-down-4no9</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/upgrading-legacy-laravel-and-moving-millions-of-rows-without-taking-the-site-down-4no9</guid>
      <description>&lt;p&gt;A while back, we got the email every team dreads a little: a payment processor we'd been integrated with for years announced it was killing its old API. Not eventually, a hard date. Move to their new SDK or lose the integration.&lt;/p&gt;

&lt;p&gt;That should've been contained. It wasn't because the new SDK wanted a different data shape underneath, not just a different way of calling the same endpoints. Customer profiles, payment methods, transaction logs, all of it had been sitting in one sprawling &lt;code&gt;users&lt;/code&gt; table for years, and the new provider needed that normalized into proper tables with external ID mappings. The SDK had also quietly dropped PHP 7 support, so the Laravel upgrade wasn't optional anymore; it was bundled into the same deadline.&lt;/p&gt;

&lt;p&gt;So the real task: upgrade the framework across several major versions, restructure a production database with millions of rows, and never take the site down while doing it. Here's how that went.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few terms first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cursor()&lt;/code&gt; and LazyCollection&lt;/strong&gt;, Laravel's way of reading a huge result set without loading it all into memory. Stream rows one at a time instead of buffering a million-row array first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunking&lt;/strong&gt;, processing a big dataset in smaller batches. Here it also means turning those batches into background jobs instead of working through them inline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyset pagination&lt;/strong&gt; (&lt;code&gt;chunkById&lt;/code&gt;), paging with &lt;code&gt;where('id', '&amp;gt;', $lastId)&lt;/code&gt; instead of &lt;code&gt;skip()-&amp;gt;take()&lt;/code&gt;. Sounds like a small difference, turned out to be the single biggest fix in this project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upsert&lt;/strong&gt;, one statement that inserts a new row or updates an existing one. Makes a job safe to retry, a worker dying mid-batch and running again doesn't create duplicates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotent jobs&lt;/strong&gt;, jobs that produce the same result no matter how many times they run. At this scale, something will eventually fail or get interrupted, and if jobs aren't idempotent, a retry becomes its own bug.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuffyv7bc5w7pfhpca5bu.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuffyv7bc5w7pfhpca5bu.png" alt="The pipeline we ended up with" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we didn't write one big migration script
&lt;/h2&gt;

&lt;p&gt;The instinct is to write a single script and let it run overnight. We didn't, on purpose. A blocking &lt;code&gt;ALTER TABLE&lt;/code&gt; or a big &lt;code&gt;INSERT INTO ... SELECT&lt;/code&gt; on a multi-million row table locks things up, and with live traffic hitting the site, that's an outage with extra steps, not a maintenance window.&lt;/p&gt;

&lt;p&gt;Instead, three stages that could each ship independently. Add the new schema alongside the old one, fully non-blocking. Backfill the new tables in the background, in small pieces, while both schemas stay live. Once the backfill is verified, flip the application code to the new tables, then clean up the old columns.&lt;/p&gt;

&lt;p&gt;Stage one was a normal migration:&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;// Phase 1 Migration: non-blocking schema creation for the new provider&lt;/span&gt;
&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&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;'user_profiles_v2'&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;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignId&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;constrained&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;cascadeOnDelete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'provider_customer_id'&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;nullable&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;index&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'preferences'&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;nullable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'formatted_phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&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;index&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestamps&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;Deliberately unexciting. Additive, doesn't touch anything the running app depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming the backfill instead of loading it all at once
&lt;/h2&gt;

&lt;p&gt;The obvious way to write this command is to grab every user that needs migrating and loop over them. At a million-plus rows, that's an instant &lt;code&gt;Allowed memory size exhausted&lt;/code&gt;, before any real work even starts.&lt;/p&gt;

&lt;p&gt;We built the command around &lt;code&gt;cursor()&lt;/code&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Console\Commands&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;App\Models\User&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;App\Jobs\MigrateUserProfileChunkJob&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\Console\Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MigrateUserProfilesCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'data:migrate-profiles'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Dispatches async backfill jobs for profile normalization'&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Unbuffered streaming keeps memory consumption under 15MB&lt;/span&gt;
        &lt;span class="nc"&gt;User&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;whereNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'migrated_at'&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;cursor&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;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&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;each&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;$chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;dispatch&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;MigrateUserProfileChunkJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;pluck&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command's job is small on purpose: read a chunk of IDs, hand them off, move on. The transformation happens elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  One upsert instead of a thousand writes
&lt;/h2&gt;

&lt;p&gt;Each dispatched job takes its chunk of IDs, builds the new payload, and writes it as a single upsert:&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;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Jobs&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;App\Models\User&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;App\Models\UserProfileV2&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;App\Helpers\PhoneHelper&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\Bus\Queueable&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\Contracts\Queue\ShouldQueue&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\Foundation\Bus\Dispatchable&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\Queue\InteractsWithQueue&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\Queue\SerializesModels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MigrateUserProfileChunkJob&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&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;Dispatchable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InteractsWithQueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Queueable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SerializesModels&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;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$userIds&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;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereIn&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userIds&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&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;$user&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="s1"&gt;'user_id'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'preferences'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;json_encode&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;legacy_preferences&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s1"&gt;'formatted_phone'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;PhoneHelper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sanitize&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;phone&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s1"&gt;'created_at'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;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;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'updated_at'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&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;toArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;UserProfileV2&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'preferences'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'formatted_phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'updated_at'&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;Fast, and also what makes retries safe. If this job dies and Laravel's queue retries it, the same upsert overwrites the same rows with the same values. No duplicates, nothing to clean up by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that only showed up at real scale
&lt;/h2&gt;

&lt;p&gt;Testing on a smaller slice of data, everything looked fine. Against the real dataset, three separate problems showed up that never would have surfaced any earlier.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4pdru0sgif0d6yijplnq.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4pdru0sgif0d6yijplnq.png" alt="Why the query got slower the deeper we went" width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offset pagination degrading with depth.&lt;/strong&gt; Plain &lt;code&gt;skip($offset)-&amp;gt;take(1000)&lt;/code&gt; was fast for the first few batches and got progressively worse the deeper in we went. At offset 500,000, a single batch took over 4 seconds. MySQL has to actually read and discard every row before your offset just to know where to start counting, half a million rows read and thrown away, every batch. Switching to keyset pagination, &lt;code&gt;where('id', '&amp;gt;', $lastProcessedId)&lt;/code&gt;, fixed it completely. 4,200ms down to 12ms, flat regardless of depth. Biggest single win in the whole project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDO's placeholder ceiling.&lt;/strong&gt; Upserting rows with more than 30 columns started throwing &lt;code&gt;PDOException: SQLSTATE[HY000]: General error: 1390 Prepared statement contains too many placeholders&lt;/code&gt;. MySQL's PDO driver caps prepared statements at 65,535 placeholders total, rows times columns, not just rows. A chunk size fine for a narrow table blew past that on a wider one. Fix: stop hardcoding chunk size, calculate it from column count, max chunk size is 65,535 divided by column count, rounded down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue workers slowly eating all available memory.&lt;/strong&gt; Left running for hours against millions of records, workers gradually consumed enough RAM that Linux started killing processes. Long-lived &lt;code&gt;queue:work&lt;/code&gt; processes hang onto query logs, event listeners, and cached Eloquent state across every job, none of it clears automatically. Fixed with &lt;code&gt;--max-jobs=1000 --max-time=3600&lt;/code&gt; so workers recycle themselves, plus an explicit &lt;code&gt;DB::disconnect()&lt;/code&gt; at the end of every chunk.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqrmvg08c002vv2himnb.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqrmvg08c002vv2himnb.png" alt="Three things that only showed up at real scale" width="799" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write every migration job assuming it'll fail partway through, because eventually one will. Upsert instead of insert, it's what makes retries safe instead of dangerous.&lt;/li&gt;
&lt;li&gt;Never load a big Eloquent collection in one step if it might grow past a few thousand rows. &lt;code&gt;cursor()&lt;/code&gt;, &lt;code&gt;chunkById()&lt;/code&gt;, or raw streaming, something that doesn't try to hold it all in memory.&lt;/li&gt;
&lt;li&gt;Keep deployment separate from schema change: add new structures first without touching anything live, backfill in the background on its own schedule, only flip the app over once the backfill is verified, and only then touch the old columns.&lt;/li&gt;
&lt;li&gt;Offset pagination looks fine in early testing and quietly falls apart at depth, test paginated queries against realistic offsets, not just page one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this needed a third-party ETL platform. Artisan commands, queue workers, and Laravel's own collection primitives were enough to move millions of rows through a live production database without anyone outside the team noticing. Full writeup with more of the backstory is on our Substack: &lt;a href="https://ucodesoft.substack.com/p/upgrading-legacy-laravel-and-moving" rel="noopener noreferrer"&gt;https://ucodesoft.substack.com/p/upgrading-legacy-laravel-and-moving&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>backend</category>
      <category>mysql</category>
    </item>
    <item>
      <title>Importing a Million Products From S3 Into Laravel, What Six Hours Taught Us</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Mon, 27 Jul 2026 03:45:26 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/importing-a-million-products-from-s3-into-laravel-what-six-hours-taught-us-3ce2</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/importing-a-million-products-from-s3-into-laravel-what-six-hours-taught-us-3ce2</guid>
      <description>&lt;p&gt;First time we ran this import, we kicked it off at 9 am, and it was still going when we packed up for the day. Six hours, and that wasn't a bad run; that's just what it did every time.&lt;/p&gt;

&lt;p&gt;The data was diamond and jewelry inventory for a client, a bit over a million SKUs, carat weight, cut, color, clarity, dimensions, pricing tiers, image references, all of it. CSV on S3, needed to land in the product database with an admin panel on top for search and filtering.&lt;/p&gt;

&lt;p&gt;Six hours means the catalog's stale for a quarter of every day. Not something we could live with long term, so we fixed it. Here's the whole thing, warts and all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick vocabulary check
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Chunking&lt;/strong&gt;, splitting a huge dataset into smaller batches instead of one giant unit. "Do a thousand rows, a thousand times" instead of "do a million at once."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A queue job&lt;/strong&gt;, work handed off to happen later, or somewhere else entirely, instead of making the thing that triggered it sit and wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming&lt;/strong&gt;, reading a file as it arrives, bit by bit, instead of waiting for the whole thing to land first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upsert&lt;/strong&gt;, one statement that inserts a new row or updates an existing one, in a single round trip instead of two or three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turning off indexes for a bulk write&lt;/strong&gt;, temporarily stopping MySQL from maintaining every index on every row, then rebuilding once the big write finishes. Only worth it for genuinely large batches.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbj5a6drcyv9hfcvgjm13.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbj5a6drcyv9hfcvgjm13.png" alt="The first version, one process doing everything" width="799" height="377"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we started with
&lt;/h2&gt;

&lt;p&gt;One artisan command doing everything in a straight line: pull the CSV from S3, parse row by row, find or create the product, update it, save it. No chunking, no queue, one PHP process, one database connection, start to finish. A million rows, a million individual Eloquent calls, each its own trip to the database.&lt;/p&gt;

&lt;p&gt;Memory gave out first, around 200,000 rows, because we were loading the whole CSV into an array before processing even started. Bumped the limit, kept going, and then the database started struggling instead; a million INSERT and UPDATE statements with zero batching means constant fsync activity. On top of that, we were downloading the entire file before touching a single row; 15 to 20 minutes gone on a bad day before anything real happened.&lt;/p&gt;

&lt;p&gt;Everything sequential, everything blocking on the last step, everything slow for its own reason. Six hours wasn't one bottleneck; it was four or five standing in line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Actually looking at what we had
&lt;/h2&gt;

&lt;p&gt;The real first move wasn't code; it was admitting this wasn't one job, it was three stitched together: get the data off S3, transform and validate a million rows, write it all to the database without falling over. Different bottleneck each time. Cram them into one process and the slowest one sets the pace for everything.&lt;/p&gt;

&lt;p&gt;The command stayed the same, &lt;code&gt;php artisan import: products&lt;/code&gt;. What happens once you run it changed almost completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming instead of downloading
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$s3Stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&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;readStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'products/latest.csv'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$csv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Reader&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;createFromStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s3Stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$csv&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setHeaderOffset&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;$chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nv"&gt;$chunkSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&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;$csv&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRecords&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;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$record&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$chunkSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ProcessProductChunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$chunk&lt;/span&gt; &lt;span class="o"&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ProcessProductChunk&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&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 command finishes in a couple minutes now, just reading a stream and firing off jobs. The real work moved to the background.&lt;/p&gt;

&lt;p&gt;1,000 rows per chunk came from actually testing sizes, not a guess. Too small and queue overhead eats you alive. Too big and one failure means redoing real work. A thousand landed us at just over a thousand jobs per run, each done in under 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batching the writes
&lt;/h2&gt;

&lt;p&gt;Original, inside each job:&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="nv"&gt;$records&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;updateOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sku'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sku'&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;mapAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&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;Reads nicely, brutal at scale, one query per row, SELECT to check, then INSERT or UPDATE. A thousand rows, up to two thousand queries in one job.&lt;/p&gt;

&lt;p&gt;Swapped for a single upsert:&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;$mapped&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="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;mapAttributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$records&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$mapped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sku'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                       &lt;span class="c1"&gt;// unique key to match on&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;updatableColumns&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;// columns to update if row exists&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One query, the whole batch. MySQL sorts out new vs. existing on its own. A chunk that took 18 seconds dropped to under 3. Bigger win than we expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running jobs in parallel
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan queue:work redis &lt;span class="nt"&gt;--queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;product-import &lt;span class="nt"&gt;--sleep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--tries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dedicated queue, kept away from notifications, PDFs, order processing, so the import doesn't starve production traffic of workers. We spin up extra workers for the duration of a run and let them wind down after.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrjldtltep95bwpoxhr3.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrjldtltep95bwpoxhr3.png" alt="The version that actually scales" width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;8 workers on 1,000-row chunks, roughly 8,000 rows every 3 seconds at peak. Same total work, just happening 8 at a time instead of 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning off indexes for the big runs
&lt;/h2&gt;

&lt;p&gt;MySQL updates every index on every write. A million rows landing with all indexes active adds real overhead. For a full re-import specifically:&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 import&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;'ALTER TABLE products DISABLE KEYS'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ... run the import ...&lt;/span&gt;

&lt;span class="c1"&gt;// After import&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;'ALTER TABLE products ENABLE KEYS'&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 products'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strictly for the full refresh case. Day-to-day incremental updates go through with indexes active the whole time, not enough volume there to justify it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The admin panel had its own unrelated problem
&lt;/h2&gt;

&lt;p&gt;Filter combinations against a million-row table with plain &lt;code&gt;where&lt;/code&gt; chains were taking 4 to 8 seconds per query, import running or not. Fixed with composite indexes matching how people actually filter:&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;// Migration&lt;/span&gt;
&lt;span class="nc"&gt;Schema&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;'products'&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;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cut'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'carat_weight'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'idx_admin_filters'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'idx_price_filter'&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;Full table scans became index lookups. 4 to 8 seconds became under 200ms. Search is still a basic &lt;code&gt;LIKE&lt;/code&gt;, fine for admin use, full-text search is still on the table for the customer-facing side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we landed
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekciwakmpa9yzynm2emq.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekciwakmpa9yzynm2emq.png" alt="What each change was actually worth" width="799" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six hours down to three or four. What's left is mostly S3 read speed and the queue's throughput ceiling, both pushable further if it ever actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A slow bulk job is usually several bottlenecks stacked together, not one, treat it that way from the start.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;updateOrCreate&lt;/code&gt; in a loop doesn't scale, a batch &lt;code&gt;upsert()&lt;/code&gt; does, same intent, one query instead of thousands.&lt;/li&gt;
&lt;li&gt;Stream large files instead of downloading them fully first, especially when processing can start before the download's done.&lt;/li&gt;
&lt;li&gt;Disabling indexes for bulk writes is a real win, but scope it to genuinely large batches, not your everyday write.&lt;/li&gt;
&lt;li&gt;A slow admin panel and a slow import can share a table without sharing a cause, profile them separately, don't assume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full writeup with more of the backstory is on our Substack: &lt;a href="https://ucodesoft.substack.com/p/importing-a-million-products-from" rel="noopener noreferrer"&gt;https://ucodesoft.substack.com/p/importing-a-million-products-from&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>aws</category>
    </item>
    <item>
      <title>Sanctum and Passport: Why We Stopped Rolling Our Own Token Auth</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:06:09 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/sanctum-and-passport-why-we-stopped-rolling-our-own-token-auth-5chk</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/sanctum-and-passport-why-we-stopped-rolling-our-own-token-auth-5chk</guid>
      <description>&lt;p&gt;For a while we did what a lot of teams do when a project needs API auth: knock together a token scheme in an afternoon. Random string, a column in the users table, check it on the way in, maybe an expiry column if someone thought of it. Works fine, until the day it doesn't. For us that day showed up as a vendor breach that had nothing to do with our own code and everything to do with a decision we'd made two years earlier.&lt;/p&gt;

&lt;p&gt;Here's what happened, and why ability scoping isn't optional on anything we ship now.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few terms first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ability (scope)&lt;/strong&gt;, a label on a token describing what it can do, &lt;code&gt;reports:read&lt;/code&gt; for example. The part that matters isn't the label, it's that your code actually checks for it before doing anything sensitive, instead of assuming a valid token means "go ahead."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation&lt;/strong&gt;, killing one token without touching the rest of the account. Sounds obvious, plenty of homegrown systems can't actually do it cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanctum&lt;/strong&gt;, Laravel's lightweight option for tokens going to things you control, your own SPA or app. No OAuth dance needed, you already trust both ends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passport&lt;/strong&gt;, the real OAuth2 implementation, for when a third party you don't control needs credentials. Grant types, refresh rotation, the works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blast radius&lt;/strong&gt;, what someone can actually do with a token once they have it. Decided the day you issue it, not the day it leaks.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffy9axb51zfukhucocuvp.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffy9axb51zfukhucocuvp.png" alt="Sanctum vs Passport, a data-shape decision" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in code
&lt;/h2&gt;

&lt;p&gt;Issuing a token with an actual scope on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only grant what this integration actually needs&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&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;createToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'reporting-integration'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;abilities&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'reports:read'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;expiresAt&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;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plainTextToken&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking that ability where it matters:&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;export&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="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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;tokenCan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reports:read'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// proceed with export&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pulling one token without touching the rest of the account:&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;// Revoke exactly one device's token, nothing else on the account&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;tokens&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;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reporting-integration'&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;/code&gt;&lt;/pre&gt;

&lt;/div&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rrft0i6wztl4ecmkjud.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rrft0i6wztl4ecmkjud.png" alt="What a scoped token's life actually looks like" width="799" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened to us
&lt;/h2&gt;

&lt;p&gt;A couple years back, a client had us hook their platform up to a third-party analytics vendor. We issued that vendor one API token, full account access, because at the time it was the only integration they had and nobody scoped it down. Got dropped into the vendor's server config and, honestly, we forgot about it. Sat there doing its job for two years.&lt;/p&gt;

&lt;p&gt;Then the vendor got breached. Not us, them, one of their internal servers got popped, and whatever was on it went with it, our token included. Since we'd never scoped it, that token wasn't "can read some analytics." It was read every customer record, touch billing, mint new tokens.&lt;/p&gt;

&lt;p&gt;We couldn't just revoke it and move on either, killing the token meant killing the whole integration, nothing narrower underneath to fall back to. And when someone asked "what could they actually have done with this," we didn't have a fast answer, we had to go endpoint by endpoint figuring out what that token's role could reach, on the clock.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wbwi9brnvvb47a0h6jp.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5wbwi9brnvvb47a0h6jp.png" alt="One leaked token, two very different outcomes" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The breach itself wasn't on us, the vendor's infrastructure was their problem. What stuck with us was that we'd left the blast radius question wide open until the worst possible time to be answering it. So we changed the default. Every external integration now gets a token scoped to exactly what it needs, with an expiry that forces someone to look at it again down the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we don't roll this ourselves anymore
&lt;/h2&gt;

&lt;p&gt;Token auth has plenty of ways to quietly break that you won't notice until you're under load or under attack, timing issues on comparison, no real revocation path, tokens that live forever. We were carrying all of that ourselves, auditing it as Laravel kept moving underneath.&lt;/p&gt;

&lt;p&gt;Handing it to Sanctum and Passport made things simpler, not more complicated. They ship alongside Laravel's own security releases, they've been through the same scrutiny as the rest of the framework, and scoping, expiry, revocation, it's just there. We didn't have to design any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping is the bit everyone skips, us included
&lt;/h2&gt;

&lt;p&gt;The thing we see teams skip most is scoping tokens properly at the start. The excuse is always "we'll tighten it up later." Later rarely comes, because tightening it up later means auditing every consumer of that token to figure out what it's actually using, and that's not a task anyone schedules until something forces it. We were exactly that team.&lt;/p&gt;

&lt;p&gt;Every token we issue now gets explicit abilities from the start, even for a client that only exists for one narrow integration. Costs nothing extra. Means the next leak, whenever it happens, was already capped before anyone knew there was a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking between Sanctum and Passport
&lt;/h2&gt;

&lt;p&gt;Not a taste call. Sanctum makes sense when you own both ends, your SPA, your app. Passport earns its weight when there's an actual outside party, real grant types, refresh tokens, client credentials for machine-to-machine.&lt;/p&gt;

&lt;p&gt;Passport on a first-party SPA is over-built. Sanctum on a public third-party integration is under-built, in a way that tends to surface at the worst time. Ask who's actually holding the token on the other end, not which package you like more.&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;// Third-party client authenticates itself, not a user&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/oauth/token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;AccessTokenController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'issueToken'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// grant_type=client_credentials&amp;amp;client_id=...&amp;amp;client_secret=...&amp;amp;scope=orders:read&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="c1"&gt;// Protecting a route with the granted scope&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:api'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'scope:orders:read'&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;'/api/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passport models an actual client with its own credentials, not just a token handed to one of your users.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we got out of it
&lt;/h2&gt;

&lt;p&gt;The payoff isn't in the initial setup. It shows up months later, when a client's laptop gets stolen and you need to kill that one device without logging out the other eleven, or a security review asks you to prove an integration can only read. With Sanctum or Passport that's a quick check. With what we had before, it's a migration and a scramble.&lt;/p&gt;

&lt;p&gt;That's the real case for not building this yourself, not that it's faster up front, but that it still holds up months later, under pressure, when whoever's dealing with it might not be the person who set it up.&lt;/p&gt;

&lt;p&gt;If you've got a token floating around with more access than it needs, worth twenty minutes to go check before it becomes an incident. Full version of this story, with more of the background, is over on our &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a href="https://ucodesoft.substack.com/p/sanctum-and-passport-why-we-stopped" rel="noopener noreferrer"&gt;https://ucodesoft.substack.com/p/sanctum-and-passport-why-we-stopped&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>authentication</category>
      <category>security</category>
    </item>
    <item>
      <title>The Index Won't Save You: Debugging a Slow Derived Table in MySQL</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:54:03 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/the-index-wont-save-you-debugging-a-slow-derived-table-in-mysql-14e2</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/the-index-wont-save-you-debugging-a-slow-derived-table-in-mysql-14e2</guid>
      <description>&lt;p&gt;Twenty rows. Over 500ms. That's the kind of mismatch that makes you stop what you're doing and open EXPLAIN. Our team ran into exactly this while working on a client's Laravel application, and it's a good enough example that we wanted to share the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before we dive in&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're already comfortable with EXPLAIN, derived tables, and correlated subqueries, skip ahead to the query. If not, here's everything you need going in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EXPLAIN&lt;/strong&gt; shows you MySQL's intended plan for a query before it runs: which indexes it's considering, roughly how many rows it expects to touch, and an &lt;code&gt;Extra&lt;/code&gt; column that flags trouble. Two phrases matter here: &lt;code&gt;Using filesort&lt;/code&gt; means it had to sort manually because no index gave it the order for free, &lt;code&gt;Using temporary&lt;/code&gt; means it had to build a scratch table to hold intermediate results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A derived table&lt;/strong&gt; is a subquery in the &lt;code&gt;FROM&lt;/code&gt; clause, wrapped so it acts like its own temporary table for the rest of the query. MySQL sometimes has to fully build that table before it can apply any filtering from the outer query, that disconnect is the core problem in this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A correlated subquery&lt;/strong&gt; references a column from the surrounding query, so it can't be evaluated once for the whole statement, it has to run per row. Where you place it matters a lot: inside a &lt;code&gt;JOIN&lt;/code&gt;, it may resolve for every joined row before anything else happens. In the &lt;code&gt;SELECT&lt;/code&gt; list, MySQL can wait until &lt;code&gt;WHERE&lt;/code&gt; and &lt;code&gt;LIMIT&lt;/code&gt; have already narrowed things down first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GROUP_CONCAT with ORDER BY&lt;/strong&gt; is grouped, ordered string aggregation, folding many rows into one value per group in a specific order. That ordering requirement is why it needs a temp table, no index gets around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A covering index&lt;/strong&gt; contains every column a query needs, so MySQL can answer from the index alone without a second trip back to the table row.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Here's the query, using a generic schema so it's easy to map onto your own. A task-management system, where every assignment has "fields," dynamic values tied to specific steps in a template, but only the steps before that template's first decision branch.&lt;/p&gt;

&lt;p&gt;Here's how the relevant tables connect:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frt1b58soumo5vzf8ijrp.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frt1b58soumo5vzf8ijrp.png" alt="diagram-3-join-vs-select" width="800" height="473"&gt;&lt;/a&gt;&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="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;Fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;Fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;field_value&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;assignments&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FieldDef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort_order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort_order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;field_value&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;assignment_fields&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;
  &lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;template_steps&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;
  &lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;template_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sort_order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;first_decision&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;template_steps&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;step_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'decision'&lt;/span&gt;
    &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;template_id&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;decision_start&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;decision_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt;
  &lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;field_defs&lt;/span&gt; &lt;span class="n"&gt;FieldDef&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FieldDef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;field_def_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort_order&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;decision_start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_decision&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignment_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;Fields&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;Fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignment_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;58&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modified&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: EXPLAIN shows two separate problems
&lt;/h2&gt;

&lt;p&gt;Not one problem, two, stacked on top of each other.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The outer scan on &lt;code&gt;Assignment&lt;/code&gt; was filesorting on &lt;code&gt;modified&lt;/code&gt; after filtering. The index in use didn't cover the sort column.&lt;/li&gt;
&lt;li&gt;The derived table (&lt;code&gt;Fields&lt;/code&gt;) was scanning its own inner &lt;code&gt;decision_start&lt;/code&gt; subquery cold, flagged with &lt;code&gt;Using temporary; Using filesort&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Treat every row of EXPLAIN as its own issue. One big index thrown at the whole query rarely fixes a compound problem like this.&lt;/p&gt;

&lt;p&gt;Here's roughly what that looked like in the plan itself:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fin4vuwqcoaypice26e04.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fin4vuwqcoaypice26e04.png" alt="diagram-2-the-wall" width="799" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: index the inner subquery
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;decision_start&lt;/code&gt; computes &lt;code&gt;MIN(sort_order)&lt;/code&gt; grouped by &lt;code&gt;template_id&lt;/code&gt;, filtered on &lt;code&gt;step_type = 'decision'&lt;/code&gt;. Unindexed, that's a full scan and sort of every step in every template.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_decision_lookup&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;template_steps&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_order&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 grouped &lt;code&gt;MIN()&lt;/code&gt; reads straight off the index in order. No temp table, no filesort, for that piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: cover the join columns
&lt;/h2&gt;

&lt;p&gt;Inside the derived table, the joins on &lt;code&gt;AF&lt;/code&gt; and &lt;code&gt;FieldDef&lt;/code&gt; were each doing an index lookup followed by a row lookup for &lt;code&gt;value&lt;/code&gt;. A covering index removes the second trip.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_af_covering&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;assignment_fields&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assignment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_def_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small per-row win, but it's inside logic that reruns per group, so it adds up fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: fix the outer filesort
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Assignment&lt;/code&gt; had &lt;code&gt;(team_id, status)&lt;/code&gt; indexed, but nothing covering &lt;code&gt;ORDER BY modified&lt;/code&gt;. Extending the index let MySQL walk it pre-sorted and stop at &lt;code&gt;LIMIT 20&lt;/code&gt;, instead of sorting the whole filtered set afterward.&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;assignments&lt;/span&gt;
  &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_team_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_team_status&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four legitimate, measurable wins. Query still slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling indexing can't get past
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Fields&lt;/code&gt; has &lt;code&gt;GROUP BY AF.assignment_id&lt;/code&gt; with an ordered &lt;code&gt;GROUP_CONCAT&lt;/code&gt; inside it. In MySQL, grouped ordered string aggregation has to materialize a temp table, there's no index that skips that. Index every join feeding into it and the aggregation step will still show &lt;code&gt;Using temporary; Using filesort&lt;/code&gt;, because that's inherent to the operation.&lt;/p&gt;

&lt;p&gt;The bigger issue: this derived table has zero awareness of &lt;code&gt;team_id = 58&lt;/code&gt;. It doesn't run after the outer &lt;code&gt;WHERE&lt;/code&gt; narrows things down, it's built first, aggregating fields for every assignment in the system, and only afterward joined down to the 20 rows you wanted. Its cost scales with total row count, not your filtered result. Indexing speeds up how each row is read on the way in, it can't stop the aggregate from running for rows you're about to throw away.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkoqwcsc7yxdw6kz8voui.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkoqwcsc7yxdw6kz8voui.png" alt="diagram-explain-mock" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Side note: the optimizer got weird after ALTER TABLE
&lt;/h2&gt;

&lt;p&gt;Adding &lt;code&gt;modified&lt;/code&gt; to the composite index should have been a clean win. Instead, EXPLAIN showed MySQL dropping the composite index entirely for &lt;code&gt;index_merge&lt;/code&gt;, intersecting old single-column indexes, filesort and all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ANALYZE TABLE assignments;&lt;/code&gt; didn't change anything on its own. What worked was removing the alternative:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;assignments&lt;/span&gt;
  &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_team_only&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the redundant single-column indexes gone, &lt;code&gt;index_merge&lt;/code&gt; wasn't an option anymore, and the composite index got used as intended. Redundant indexes cost more than write overhead, they give the optimizer worse choices, and it sometimes takes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual fix: bound the aggregate to the filtered rows
&lt;/h2&gt;

&lt;p&gt;Two ways to do this. They're not equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A, correlated subquery in the join.&lt;/strong&gt; Looks reasonable, often isn't:&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;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;field_value&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;assignment_fields&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignment_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;Fields&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL 5.7 often runs this as a dependent join, re-executing the inner query once per outer row via a join buffer. Can end up slower than the original derived table, since you've traded one big aggregation for many small dependent ones with no early cutoff benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B, correlated scalar subqueries in the SELECT list.&lt;/strong&gt; This one works:&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="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FieldDef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort_order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;assignment_fields&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;
    &lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;template_steps&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;step_id&lt;/span&gt;
    &lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;field_defs&lt;/span&gt; &lt;span class="n"&gt;FieldDef&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;FieldDef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;field_def_id&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;AF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignment_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort_order&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sort_order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;template_steps&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;template_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;template_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;step_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'decision'&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;
  &lt;span class="c1"&gt;-- field_value mirrors the same shape&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;assignments&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;58&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modified&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scalar subquery in the &lt;code&gt;SELECT&lt;/code&gt; list runs per output row, after &lt;code&gt;WHERE&lt;/code&gt; has narrowed things, and after &lt;code&gt;LIMIT&lt;/code&gt; has already decided which rows need it at all. MySQL can often stop once it has its 20 rows. A join-based correlated subquery gets no such benefit, it resolves for every row the join touches before &lt;code&gt;LIMIT&lt;/code&gt; is applied.&lt;/p&gt;

&lt;p&gt;Same core idea, per-row correlation instead of a global aggregate, very different execution shape.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2gu7lrd1g3fo2jq8l2sl.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2gu7lrd1g3fo2jq8l2sl.png" alt="diagram-0-relationships" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat every EXPLAIN row as its own problem. A slow query can have several unrelated causes stacked together, fixing one won't even register in the timing until the rest are gone too.&lt;/li&gt;
&lt;li&gt;Grouped, ordered aggregation (&lt;code&gt;GROUP_CONCAT(... ORDER BY ...) GROUP BY x&lt;/code&gt;) always materializes. No index removes that, it's a property of the operation.&lt;/li&gt;
&lt;li&gt;A derived table with &lt;code&gt;GROUP BY&lt;/code&gt; is a wall the optimizer can't see through, your outer &lt;code&gt;WHERE&lt;/code&gt; filters won't get pushed into it no matter how selective they are.&lt;/li&gt;
&lt;li&gt;Redundant single-column indexes aren't free even when unused for writes, they widen the optimizer's (sometimes worse) options.&lt;/li&gt;
&lt;li&gt;"Correlated subquery" isn't a single technique, where you put it (&lt;code&gt;JOIN&lt;/code&gt; vs &lt;code&gt;SELECT&lt;/code&gt; list) decides whether &lt;code&gt;LIMIT&lt;/code&gt; can help you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Indexing gets you most of the way there, right up until the operation itself is the bottleneck. At that point the fix isn't a better index, it's changing what you're actually asking MySQL to compute.&lt;/p&gt;

&lt;p&gt;We work through problems like this regularly on Laravel and MySQL systems at scale. If you've hit a similar wall, drop a comment, we're always up for talking through a gnarly query.&lt;/p&gt;

&lt;p&gt;We also cover this kind of debugging in more depth over on our Substack: &lt;a href="https://ucodesoft.substack.com/p/the-index-wont-save-you-a-slow-query" rel="noopener noreferrer"&gt;https://ucodesoft.substack.com/p/the-index-wont-save-you-a-slow-query&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>sql</category>
      <category>performance</category>
    </item>
    <item>
      <title>The "Laravel Isn't Secure Enough for Enterprise" Myth is Dead</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:46:15 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/the-laravel-isnt-secure-enough-for-enterprise-myth-is-dead-30g4</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/the-laravel-isnt-secure-enough-for-enterprise-myth-is-dead-30g4</guid>
      <description>&lt;p&gt;Every backend engineer has heard it: &lt;em&gt;"Laravel is great for MVPs and small projects, but it’s just not secure enough for enterprise scale."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Usually, this critique comes from people who haven't looked at the modern ecosystem, or who are judging the framework based on a poorly configured legacy codebase they inherited from a junior team.&lt;/p&gt;

&lt;p&gt;The reality? Laravel bakes in more native security defaults than most backend frameworks require you to configure manually. If an application suffers from basic vulnerabilities like SQL injection or Cross-Site Scripting (XSS), it is almost always a result of explicit implementation choices—not framework limitations.&lt;/p&gt;

&lt;p&gt;As &lt;strong&gt;Certified Laravel Partners&lt;/strong&gt; (&lt;a href="https://laravel.com/partners/ucodesoft" rel="noopener noreferrer"&gt;https://laravel.com/partners/ucodesoft&lt;/a&gt;), doing deep-dive security audits is a standard baseline for every enterprise migration and build we take on. Here is what Laravel provides out of the box, and how to leverage it properly at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Hardened Front-End Defaults (XSS &amp;amp; CSRF)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Many frameworks leave Cross-Site Request Forgery (CSRF) and Cross-Site Scripting (XSS) up to manual configuration or third-party middleware. Laravel treats them as non-negotiable baselines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blade Output Escaping:&lt;/strong&gt; The &lt;code&gt;{{ $variable }}&lt;/code&gt; syntax automatically routes data through PHP’s &lt;code&gt;htmlspecialchars&lt;/code&gt; function. To introduce an XSS vulnerability, a developer must intentionally use raw tags (&lt;code&gt;{!! $variable !!}&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mandatory CSRF:&lt;/strong&gt; Every state-changing HTTP request (POST, PUT, DELETE) is intercepted by native CSRF middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. First-Party API Authentication&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enterprise apps require robust, scalable token authentication. Instead of gambling on unmaintained open-source packages, Laravel provides two native, first-party, continuously patched solutions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laravel Sanctum:&lt;/strong&gt; Lightweight token/cookie authentication perfect for SPAs and mobile apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel Passport:&lt;/strong&gt; A full OAuth2 server implementation built on top of the League OAuth2 server package.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because these are first-party utilities, they integrate seamlessly with the framework's authentication guards and receive immediate security patches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Native Protection Against SQL Injection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SQL injection remains a massive threat to enterprise databases. Laravel’s Eloquent ORM and Query Builder mitigate this entirely by using &lt;strong&gt;PDO parameter binding&lt;/strong&gt; for all database operations.&lt;/p&gt;

&lt;p&gt;Input data is bound to parameters rather than being directly concatenated into raw SQL strings. A developer has to explicitly bypass Eloquent and deliberately misuse raw queries (e.g., &lt;code&gt;DB::raw()&lt;/code&gt;) to expose the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Isolated, Testable Authorization Policies&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In legacy enterprise codebases, permission checks often become a tangled mess of &lt;code&gt;if/else&lt;/code&gt; statements scattered across controllers and views. This fragmentation makes it incredibly easy to miss a check.&lt;/p&gt;

&lt;p&gt;Laravel solves this architecturally through &lt;strong&gt;Policies&lt;/strong&gt;. By isolating authorization rules into discrete, dedicated classes mapped to specific models, you can unit-test your permission logic in complete isolation:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
php
// Clean, isolated, and highly testable enterprise authorization
public function update(User $user, Order $order)
{
    return $user-&amp;gt;id === $order-&amp;gt;user_id
        ? Response::allow()
        : Response::deny('You do not own this order.');
}

**Security Requires Configuration Discipline**

A framework can provide the most secure foundation in the world, but it still requires team discipline to maintain. In our enterprise engagements, the most critical vulnerabilities we uncover are rarely framework flaws. They are human errors:

* Leaving `APP_DEBUG=true` in production environments.
* Broad, insecure CORS header configurations (`*`).
* Failing to rotate or protect the `APP_KEY`.

Laravel doesn't eliminate the need for skilled, disciplined engineers—but it ensures your team doesn't waste hundreds of hours reinventing basic security compliance.

**Let's discuss in the comments:**
If you've audited an inherited Laravel codebase at scale, what was the most terrifying security configuration oversight you uncovered?

*Need an architectural or security review for your application? Get in touch with our team at (https://ucodesoft.com/contact-us).*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Seven things production taught me that no tutorial did</title>
      <dc:creator>UCodeSoft</dc:creator>
      <pubDate>Tue, 14 Jul 2026 16:57:56 +0000</pubDate>
      <link>https://dev.to/ucodesoft_0ffeef866/seven-things-production-taught-me-that-no-tutorial-did-3j2o</link>
      <guid>https://dev.to/ucodesoft_0ffeef866/seven-things-production-taught-me-that-no-tutorial-did-3j2o</guid>
      <description>&lt;p&gt;I spent the last few months building out a shipping integration on top of Shippo, and along the way, I kept running into the same lesson: the "quick" way to write Laravel code almost always works — right up until it doesn't. This isn't a theory post. Every pattern below came from something that actually broke, or almost broke, in this codebase.&lt;/p&gt;

&lt;p&gt;Here are seven things I'd tell a past version of myself before writing another shipment controller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Stop passing raw arrays to third-party APIs.&lt;/strong&gt;&lt;br&gt;
The ticket says "wire up Shippo shipment creation." The fastest way to close it is to grab a few fields off the order, throw them into an array, and fire it at the HTTP client. It works on the first try. It works in the demo. So what's wrong with it?&lt;/p&gt;

&lt;p&gt;Nothing — until six months from now, when a new dev who's never opened Shippo's docs calls your method, forgets that parcels need a distance_unit key, and finds out the hard way via a 422 in staging. The array approach doesn't fail where the mistake happens. It fails at the network boundary, with a stack trace pointing at your HTTP client instead of the line that actually got it wrong.&lt;/p&gt;

&lt;p&gt;My first instinct was "Shippo owns this shape, so a DTO is just re-describing their API." That's backwards. The DTO isn't there to constrain Shippo — it's there to constrain me. It makes it structurally impossible to send Shippo something malformed, and it gives the next person a single file that answers "what does a shipment request actually need?"&lt;/p&gt;

&lt;p&gt;phpfinal readonly class ShippoParcel&lt;br&gt;
{&lt;br&gt;
    public function __construct(&lt;br&gt;
        public float $lengthCm,&lt;br&gt;
        public float $widthCm,&lt;br&gt;
        public float $heightCm,&lt;br&gt;
        public float $weightKg,&lt;br&gt;
    ) {}&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public function toPayload(): array
{
    return [
        'length' =&amp;gt; (string) $this-&amp;gt;lengthCm,
        'width' =&amp;gt; (string) $this-&amp;gt;widthCm,
        'height' =&amp;gt; (string) $this-&amp;gt;heightCm,
        'distance_unit' =&amp;gt; 'cm',
        'weight' =&amp;gt; (string) $this-&amp;gt;weightKg,
        'mass_unit' =&amp;gt; 'kg',
    ];
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;toPayload() is doing something specific: it's the one place in the codebase that knows Shippo wants distance_unit and mass_unit as separate string keys instead of floats. That's a Shippo quirk, not a domain concept, and it deserves to live exactly once — at the edge — instead of being copy-pasted into every place that builds a parcel array.&lt;/p&gt;

&lt;p&gt;Do the same thing on the response side. If a successful transaction contains a tracking_number buried three levels deep, don't make every consumer memorize that. Wrap it in a response DTO and throw a domain exception right there if a field you depend on is missing, instead of discovering it three calls later when something null-derefs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Contextual binding: stop putting environment checks inside your classes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most of the time, one global container binding is exactly right. Don't reach for contextual binding before you need it — that's its own flavor of overengineering. But there's a specific smell that tells you when you've outgrown it: a service class that checks config() or does an instanceof check to decide its own behavior. That class just took on a second job — deciding which implementation it should be — and that job belongs to the container.&lt;/p&gt;

&lt;p&gt;Here's a real one from this project: staging needs to hit Shippo's actual sandbox API so QA can test the full flow. But the internal support tool — the one an ops person uses to reprint a lost label — needs live credentials even when it's deployed next to staging, because a support agent reprinting a real label needs a real tracking number. An APP_ENV check can't express that; both consumers live in the same deployment.&lt;/p&gt;

&lt;p&gt;php$this-&amp;gt;app-&amp;gt;when(QaShipmentController::class)&lt;br&gt;
    -&amp;gt;needs(ShippoClient::class)&lt;br&gt;
    -&amp;gt;give(SandboxShippoHttpClient::class);&lt;/p&gt;

&lt;p&gt;$this-&amp;gt;app-&amp;gt;when(SupportLabelController::class)&lt;br&gt;
    -&amp;gt;needs(ShippoClient::class)&lt;br&gt;
    -&amp;gt;give(ShippoHttpClient::class);&lt;/p&gt;

&lt;p&gt;Both controllers just type-hint ShippoClient. Neither has an if branch. Neither can drift onto the wrong credentials because someone forgot an env check.&lt;/p&gt;

&lt;p&gt;One thing I'd flag: when you use the closure form of give() to resolve something like a region-specific storage driver, make the unmatched case throw instead of silently falling back to a default region.&lt;/p&gt;

&lt;p&gt;php$this-&amp;gt;app-&amp;gt;when(LabelArchiver::class)&lt;br&gt;
    -&amp;gt;needs(StorageDriverInterface::class)&lt;br&gt;
    -&amp;gt;give(function ($app) {&lt;br&gt;
        return match (config('services.region')) {&lt;br&gt;
            'eu' =&amp;gt; $app-&amp;gt;make(S3EuStorageDriver::class),&lt;br&gt;
            'us' =&amp;gt; $app-&amp;gt;make(S3UsStorageDriver::class),&lt;br&gt;
            default =&amp;gt; throw new RuntimeException('Unconfigured region: no storage driver bound.'),&lt;br&gt;
        };&lt;br&gt;
    });&lt;/p&gt;

&lt;p&gt;A misconfigured deployment failing loudly at boot is a five-minute fix. The same misconfiguration that silently archives EU customer data to a US bucket for three weeks is a very different conversation with legal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. API versioning: the database doesn't get an opinion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The tempting shortcut when a mobile endpoint needs to change: just change it, ship a new app version, tell stragglers to update. It's less work than standing up parallel routes and resources for what might be a one-field difference.&lt;/p&gt;

&lt;p&gt;Here's why that doesn't survive contact with mobile clients: you can't force an update. A web deploy hits everyone within minutes. A phone with your app from fourteen months ago, sitting in a drawer half the year, hits your API tomorrow with the same confidence as someone on today's build. You can't patch that client — you can only decide, in advance, how long you're willing to keep talking to it.&lt;/p&gt;

&lt;p&gt;The rule I hold myself to now: version at the boundary, never inside business logic. The second a version check shows up inside a service class, that check is going to outlive the version it was written for.&lt;/p&gt;

&lt;p&gt;phpfinal class ShipmentController extends V1\ShipmentController&lt;br&gt;
{&lt;br&gt;
    protected function resource(Shipment $shipment): JsonResource&lt;br&gt;
    {&lt;br&gt;
        return ShipmentResource::make($shipment);&lt;br&gt;
    }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;If v2 only changes the response shape, don't fork the whole controller — fork the resource and let v2 inherit the rest. Duplicating the entire controller feels safer in the moment, but now every bug fix has to be applied twice by someone who has to remember both files exist.&lt;/p&gt;

&lt;p&gt;And tell clients a version is dying using something machine-readable, not a changelog nobody on the client side reads:&lt;/p&gt;

&lt;p&gt;php$response-&amp;gt;headers-&amp;gt;set('Deprecation', 'true');&lt;br&gt;
$response-&amp;gt;headers-&amp;gt;set('Sunset', 'Wed, 01 Oct 2026 00:00:00 GMT');&lt;/p&gt;

&lt;p&gt;Then actually count requests against the old version after your sunset date. That count is the only honest answer to "can we turn this off yet" — not a guess from support tickets.&lt;/p&gt;

&lt;p&gt;The part that actually burns teams isn't the routing layer; it's migrations. Add new columns as nullable, write to both old and new during the transition, confirm traffic on the old version has actually dropped to zero, and only then drop the legacy column in a separate deploy. "Low" traffic isn't zero traffic — one enterprise customer's warehouse scanner still hitting v1 nightly is enough to turn a routine cleanup into an on-call incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Real-time dashboards: broadcasting demos beautifully and then lying to you&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before reaching for WebSockets at all — just poll. setInterval, a fetch call, done. For a lot of internal dashboards, that's genuinely the right answer, not a lesser one. Broadcasting earns its complexity when both the update volume and the number of watchers climb.&lt;/p&gt;

&lt;p&gt;What the "add a trait, save a model, watch it update live" demo doesn't show you: broadcasts that silently never arrive, events that land out of order, and clients that missed a whole window of updates because their laptop slept for two minutes.&lt;/p&gt;

&lt;p&gt;A shipment tracking dashboard hits all three because "label purchased" and "in transit" webhooks can land milliseconds apart, and normal network jitter is enough to flip that order by the time it reaches a browser. The fix is a version field that the client can compare against:&lt;/p&gt;

&lt;p&gt;phppublic function broadcastWith(string $event): array&lt;br&gt;
{&lt;br&gt;
    return match ($event) {&lt;br&gt;
        'updated' =&amp;gt; [&lt;br&gt;
            'id' =&amp;gt; $this-&amp;gt;id,&lt;br&gt;
            'status' =&amp;gt; $this-&amp;gt;status,&lt;br&gt;
            'version' =&amp;gt; $this-&amp;gt;updated_at-&amp;gt;getTimestampMs(),&lt;br&gt;
        ],&lt;br&gt;
        default =&amp;gt; [],&lt;br&gt;
    };&lt;br&gt;
}&lt;br&gt;
jsEcho.private(&lt;code&gt;shipments.${shipmentId}&lt;/code&gt;).listen('.ShipmentUpdated', (e) =&amp;gt; {&lt;br&gt;
    if (e.version &amp;lt;= (lastSeenVersion[e.id] || 0)) return; // stale, ignore&lt;br&gt;
    lastSeenVersion[e.id] = e.version;&lt;br&gt;
    updateDashboard(e.status, e.trackingNumber);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;The one everybody skips: reconnects. A dropped socket has zero memory of what it missed — there's no replay buffer. On reconnect, refetch the current state from a plain REST endpoint before trusting the next broadcast. If you skip this, you'll eventually get a bug report that reads "the dashboard says pending but it actually shipped hours ago," and it'll take you a minute to realize it's not a broadcasting bug at all — it's a missing REST call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. N+1 queries hide behind conditionals, not in obvious loops&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nobody writes an obvious N+1 loop and misses it in review. The ones that ship are nested two or three levels deep inside a branch that only fires for a subset of rows:&lt;/p&gt;

&lt;p&gt;php$shipments = Shipment::with('customer')-&amp;gt;get();&lt;br&gt;
foreach ($shipments as $shipment) {&lt;br&gt;
    if ($shipment-&amp;gt;customer-&amp;gt;isHighVolume()) {&lt;br&gt;
        // N+1 fires here — parcels were never eager loaded&lt;br&gt;
        $shipment-&amp;gt;parcels-&amp;gt;each(fn ($p) =&amp;gt; $p-&amp;gt;carrier-&amp;gt;name);&lt;br&gt;
    }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;with('customer') only reaches one level deep. This is invisible in review unless someone mentally traces every branch two relations deep, and invisible in local dev because your ten-seeded shipments run ten queries instead of ten thousand. It shows up as connection pool exhaustion in production, during your busiest hour — not as a comment in a PR.&lt;/p&gt;

&lt;p&gt;The actual fix isn't "remember to eager load" — memory isn't an engineering control. Make the framework refuse to ship the mistake:&lt;/p&gt;

&lt;p&gt;phpModel::preventLazyLoading(! $this-&amp;gt;app-&amp;gt;isProduction());&lt;br&gt;
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {&lt;br&gt;
    logger()-&amp;gt;warning("Attempted to lazy load [{$relation}] on model [" . get_class($model) . "].");&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;Throw in local and staging, where a broken deploy costs nothing. Log-and-allow in production, where throwing on a lazy load would turn a performance bug into an outage on the spot. And enable this in your test suite too — a test that seeds exactly one shipment with one parcel will never trip an N+1, because there's nothing to multiply.&lt;/p&gt;

&lt;p&gt;For the fix itself, reach for loadMissing() instead of sprinkling with() everywhere defensively. It costs nothing if the caller already did the right thing, and acts as a safety net at every controller/job boundary if they didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Cache::flush() is a sledgehammer with a hair trigger&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A carrier's negotiated rate changes, something needs invalidating, and Cache::flush() is sitting right there — one line, guaranteed correct, impossible to get subtly wrong. It also wipes every unrelated cached value in your app and triggers a stampede as every concurrent request misses at once and hits the database simultaneously.&lt;/p&gt;

&lt;p&gt;Cache tags scope the blast radius to exactly what changed:&lt;/p&gt;

&lt;p&gt;phpCache::tags(['rates', "carrier:{$carrierId}"])-&amp;gt;put(&lt;br&gt;
    "rate.{$originZip}.{$destZip}", $rateQuote, now()-&amp;gt;addHours(6)&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;Cache::tags(["carrier:{$carrierId}"])-&amp;gt;flush();&lt;/p&gt;

&lt;p&gt;Wire the flush to the model lifecycle so it's correct by construction instead of by every developer remembering to add an invalidation line next to every write:&lt;/p&gt;

&lt;p&gt;phpprotected static function booted(): void&lt;br&gt;
{&lt;br&gt;
    static::saved(function (CarrierAccount $account): void {&lt;br&gt;
        if ($account-&amp;gt;wasChanged('negotiated_rate_table')) {&lt;br&gt;
            Cache::tags(["carrier:{$account-&amp;gt;id}"])-&amp;gt;flush();&lt;br&gt;
        }&lt;br&gt;
    });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Two things worth knowing before you build around this: cache tags require Redis or Memcached — the file and database drivers throw at runtime, not fail gracefully. And flush after your transaction commits, not before. Flush too early, and a request landing in that window reads the pre-update row, caches it, and now you're serving stale data for the full TTL with nothing telling you it happened.&lt;/p&gt;

&lt;p&gt;Laravel 13 also adds Cache::touch(), which is worth knowing about for the more common case: a key is still valid, and you just want to push the TTL out without paying to rewrite the payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Model::all() works fine until your table grows&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's one call, it reads clean, and it's the answer in every tutorial. On a few hundred rows, it's genuinely correct. On a table with hundreds of thousands of open shipments, it's an OOM before your loop runs a single iteration — and the failure has nothing to do with your loop logic. It happens at the query level.&lt;/p&gt;

&lt;p&gt;The choice comes down to two questions: does your loop mutate the column you're filtering on, and is each iteration doing slow I/O?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Read-only, fast → chunk()&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Mutates the filtered column → chunkById() — pages by primary key instead of offset, so a row changing status mid-loop can't silently shift what the next page fetches (this is the one that bites people: chunk() under mutation doesn't error, it just quietly skips rows)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Slow outbound I/O → lazyById() — same generator ergonomics as cursor(), but doesn't hold a database connection open for the full duration of every outbound API call&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;phpShipment::where('status', 'in_transit')&lt;br&gt;
    -&amp;gt;lazyById(500)&lt;br&gt;
    -&amp;gt;each(function (Shipment $shipment): void {&lt;br&gt;
        $shipment-&amp;gt;refreshTrackingStatus();&lt;br&gt;
        $shipment-&amp;gt;save();&lt;br&gt;
    });&lt;/p&gt;

&lt;p&gt;Get the second question wrong, and you trade an OOM for silently under-processing your table — which is arguably worse, because nothing throws to tell you it happened.&lt;/p&gt;

&lt;p&gt;None of these is exotic. They're all things Laravel gives you out of the box. The theme across all seven, if there is one, is the same: local dev and a small seed dataset will hide every one of these problems from you, and production will find every single one at the worst possible time. Building the guardrail in — a DTO, a container binding, a lazy-loading exception, a version header — beats trying to remember not to make the mistake.&lt;/p&gt;

&lt;p&gt;If you've hit a different version of any of these, I'd genuinely like to hear it — drop it in the comments.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
