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