<?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: Saqueib Ansari</title>
    <description>The latest articles on DEV Community by Saqueib Ansari (@saqueib).</description>
    <link>https://dev.to/saqueib</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%2F3826808%2Fe6a01e4e-75be-4474-bfb1-87c09122c718.jpeg</url>
      <title>DEV Community: Saqueib Ansari</title>
      <link>https://dev.to/saqueib</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saqueib"/>
    <language>en</language>
    <item>
      <title>Before You Blame Filament, Profile the Screen</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Fri, 17 Jul 2026 05:13:03 +0000</pubDate>
      <link>https://dev.to/saqueib/before-you-blame-filament-profile-the-screen-3j0k</link>
      <guid>https://dev.to/saqueib/before-you-blame-filament-profile-the-screen-3j0k</guid>
      <description>&lt;p&gt;If a Filament admin screen feels slow, the framework is usually not the first thing you should blame. That instinct is understandable because Filament sits at the visible layer. But in real Laravel apps, the slowdown usually comes from &lt;strong&gt;query shape, relation loading, policy checks, table configuration, and repeated per-row work&lt;/strong&gt;. Filament just makes those mistakes more obvious because it gives you powerful abstractions and a lot of interactive surface area.&lt;/p&gt;

&lt;p&gt;That is exactly why &lt;strong&gt;Filament performance testing&lt;/strong&gt; matters. Without profiling, teams jump straight to framework-level fixes, Livewire paranoia, or cargo-cult caching. The result is wasted time and a slower codebase that becomes harder to reason about. The better approach is narrower and more boring: profile the specific screen, identify the hot path, and fix the actual bottleneck.&lt;/p&gt;

&lt;p&gt;This article is opinionated on purpose. &lt;strong&gt;Do not optimize Filament globally. Optimize one screen at a time.&lt;/strong&gt; If you cannot point to the exact query, callback, policy, widget, or relation manager causing the slowdown, you are not doing performance work yet. You are guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With a Single Screen, Not a General Complaint
&lt;/h2&gt;

&lt;p&gt;Most admin performance discussions start too vaguely.&lt;/p&gt;

&lt;p&gt;Someone says the panel is slow. Another person blames Livewire. Someone else suggests Redis, queueing, Octane, or server upgrades. All of that is premature if the actual problem is a resource table firing 120 queries because three columns were made searchable across relations.&lt;/p&gt;

&lt;p&gt;Filament screens are not uniform. A dashboard widget page, a resource index, an edit form, a relation manager, and a global search interaction each fail differently. If you test them as one system, the signal gets muddy fast.&lt;/p&gt;

&lt;p&gt;The first rule is to isolate the problem into a page-level benchmark. Pick one slow screen and answer these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the problem on initial load, or only after filters/search/sort?&lt;/li&gt;
&lt;li&gt;Is the page slow for every user, or only users with more permissions or larger datasets?&lt;/li&gt;
&lt;li&gt;Is the time spent in SQL, authorization, rendering, or repeated component work?&lt;/li&gt;
&lt;li&gt;Does the slowdown scale with row count, relation depth, or visible widgets?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds basic, but teams skip it constantly.&lt;/p&gt;

&lt;p&gt;A practical profiling pass should test these screens separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;resource index pages&lt;/li&gt;
&lt;li&gt;edit pages with relation managers&lt;/li&gt;
&lt;li&gt;dashboard pages with widgets and stats&lt;/li&gt;
&lt;li&gt;modal actions and bulk actions&lt;/li&gt;
&lt;li&gt;global search results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one page is slow, do not treat the whole panel as slow. &lt;strong&gt;Filament is a container for many performance profiles, not one performance profile.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Build a Simple Local Baseline
&lt;/h3&gt;

&lt;p&gt;You do not need a perfect observability stack to start. You need consistent numbers.&lt;/p&gt;

&lt;p&gt;In a Laravel app, the fastest baseline usually comes from &lt;strong&gt;Laravel Debugbar&lt;/strong&gt;, &lt;strong&gt;Telescope&lt;/strong&gt;, query logs, and request timing around the specific Livewire interaction. For local work, that is enough to get to the truth quickly.&lt;/p&gt;

&lt;p&gt;A lightweight query/timing baseline can look like this:&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="no"&gt;Illuminate\Support\Facades\DB&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\Support\Facades\Log&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;flushQueryLog&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;enableQueryLog&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$startedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;app&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;terminating&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;$startedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$queries&lt;/span&gt; &lt;span class="o"&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;getQueryLog&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'filament-screen-profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'duration_ms'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$startedAt&lt;/span&gt;&lt;span class="p"&gt;)&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'query_count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;$queries&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'slowest_queries'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$queries&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;sortByDesc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'time'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That code is intentionally unglamorous. Good. Performance work should start with facts, not tooling theatre.&lt;/p&gt;

&lt;p&gt;When you profile a Filament screen, record these values every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;total request duration&lt;/li&gt;
&lt;li&gt;total query count&lt;/li&gt;
&lt;li&gt;slowest queries by time&lt;/li&gt;
&lt;li&gt;peak memory if the page loads large collections&lt;/li&gt;
&lt;li&gt;row count displayed&lt;/li&gt;
&lt;li&gt;which interaction triggered the work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is easy to miss. A Filament page may be acceptable on first render and terrible when sorting by a related column, opening a modal, switching tabs, or loading a relation manager. If you only test the first page hit, you will miss the expensive path that users actually feel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most "Filament Is Slow" Problems Are Really Query Problems
&lt;/h2&gt;

&lt;p&gt;The most common cause of a slow Filament page is not rendering. It is &lt;strong&gt;bad data loading strategy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Filament makes it easy to define expressive tables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;relationship-backed columns&lt;/li&gt;
&lt;li&gt;counts and badges&lt;/li&gt;
&lt;li&gt;computed text&lt;/li&gt;
&lt;li&gt;searchable related fields&lt;/li&gt;
&lt;li&gt;sortable related fields&lt;/li&gt;
&lt;li&gt;tab counts&lt;/li&gt;
&lt;li&gt;conditional state based on related models&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those features can be fine. The problem is when they stack without an explicit query strategy.&lt;/p&gt;

&lt;p&gt;Here is a pattern that looks clean and often performs badly:&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Table&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Table&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;$table&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'company.name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;searchable&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;sortable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'roles.name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projects_count'&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;counts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projects'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'latestInvoice.status'&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;badge&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'owner.email'&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;toggleable&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;Nothing here is obviously wrong. That is why teams get trapped.&lt;/p&gt;

&lt;p&gt;The hidden problem is that this table definition is also a data access definition. Search across &lt;code&gt;company.name&lt;/code&gt; can force joins or subqueries. Rendering &lt;code&gt;roles.name&lt;/code&gt; can touch collections that were not eager loaded properly. Sorting on related state can generate ugly SQL. Showing &lt;code&gt;latestInvoice.status&lt;/code&gt; may trigger relation access patterns you did not think about.&lt;/p&gt;

&lt;p&gt;The fix is not to stop using Filament features. The fix is to make the page query explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Define a Screen-Specific Query Contract
&lt;/h3&gt;

&lt;p&gt;A strong default is to override the resource query and load only what the screen actually 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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Database\Eloquent\Builder&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getEloquentQuery&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Builder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getEloquentQuery&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;select&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="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'company_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'owner_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'latest_invoice_id'&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;'created_at'&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;with&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'company:id,name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'owner:id,email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'latestInvoice:id,customer_id,status,due_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'roles:id,name'&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;withCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'projects'&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;This is not exciting code, but it is the kind of code that makes admin panels fast.&lt;/p&gt;

&lt;p&gt;A few practical rules matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Select only the columns the page needs.&lt;/strong&gt; Pulling full row payloads for every model in an admin table is lazy and expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narrow eager loads aggressively.&lt;/strong&gt; &lt;code&gt;with('company')&lt;/code&gt; is acceptable when prototyping, not when debugging a slow production screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;withCount()&lt;/code&gt;, &lt;code&gt;withExists()&lt;/code&gt;, and constrained eager loads deliberately.&lt;/strong&gt; Let SQL do the aggregation once instead of letting PHP rediscover it row by row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid hidden relation access in accessors.&lt;/strong&gt; Accessors that look elegant in models can destroy table performance because they hide expensive reads behind attribute access.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Run &lt;code&gt;EXPLAIN&lt;/code&gt; Before You Touch Caching
&lt;/h3&gt;

&lt;p&gt;If one query dominates the timeline, inspect the query plan before you add caching or try to "optimize Filament." In a lot of cases, the table is simply revealing schema weaknesses.&lt;/p&gt;

&lt;p&gt;Common problems include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;missing indexes on foreign keys used in filters&lt;/li&gt;
&lt;li&gt;missing indexes on status or date columns used in sorting&lt;/li&gt;
&lt;li&gt;relationship search hitting large unindexed text fields&lt;/li&gt;
&lt;li&gt;composite queries that need multi-column indexes but only have single-column indexes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Filament does not create those problems. It just makes them visible because admin tables are query-heavy by nature.&lt;/p&gt;

&lt;p&gt;If a slow screen depends on filtering orders by &lt;code&gt;team_id&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and &lt;code&gt;created_at&lt;/code&gt;, and you only indexed &lt;code&gt;team_id&lt;/code&gt;, the panel will feel slow no matter how clean the Filament configuration is.&lt;/p&gt;

&lt;p&gt;This is why performance testing should stay grounded in database behavior. &lt;strong&gt;If the SQL is bad, the UI framework is not the bottleneck.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Table Configuration Can Create Death by a Thousand Cuts
&lt;/h2&gt;

&lt;p&gt;Even when the main query is fine, Filament tables can still feel slow because of repeated per-row work. This is where performance slips from "one obvious bad query" into a more annoying pattern: lots of individually reasonable decisions that add up badly.&lt;/p&gt;

&lt;p&gt;Per-row formatting, badge color callbacks, visibility rules, icon logic, and custom state derivation all execute inside the rendering loop. On a 25-row page, maybe that is harmless. On a 100-row page with multiple relation-backed columns, it becomes expensive quickly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watch for Queries Inside Column Logic
&lt;/h3&gt;

&lt;p&gt;This pattern is common and usually wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&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;badge&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;formatStateUsing&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;$state&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;latestInvoice&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_overdue&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'Overdue'&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ucfirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$state&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;color&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;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orders&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;'priority'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'high'&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;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'danger'&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'gray'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;exists()&lt;/code&gt; call looks tiny. It is not tiny when it runs once per row.&lt;/p&gt;

&lt;p&gt;This is the kind of mistake that gets blamed on Livewire diffing or framework overhead when the real issue is simple: you embedded a query decision inside presentation code.&lt;/p&gt;

&lt;p&gt;A better pattern is to push the expensive logic into the base query:&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;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getEloquentQuery&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Builder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getEloquentQuery&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;with&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'latestInvoice:id,customer_id,is_overdue'&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;withExists&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'orders as has_high_priority_orders'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;$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="nv"&gt;$query&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;'priority'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'high'&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;Then keep the table definition dumb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;TextColumn&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&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;badge&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;formatStateUsing&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;$state&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;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;latestInvoice&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_overdue&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'Overdue'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ucfirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$state&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;color&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;$record&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;has_high_priority_orders&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'danger'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'gray'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That principle scales well: &lt;strong&gt;precompute expensive truth once, render it many times cheaply&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Be Skeptical of Overly Smart Columns
&lt;/h3&gt;

&lt;p&gt;Filament lets you build rich tables quickly. That does not mean every nice-looking column is worth the cost.&lt;/p&gt;

&lt;p&gt;These features deserve scrutiny when a screen is slow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;searchable relationship columns&lt;/li&gt;
&lt;li&gt;sortable computed columns&lt;/li&gt;
&lt;li&gt;badges based on multiple relations&lt;/li&gt;
&lt;li&gt;icon and color callbacks that inspect extra state&lt;/li&gt;
&lt;li&gt;columns driven by accessors with hidden query reads&lt;/li&gt;
&lt;li&gt;wide tables with many toggleable but still computed columns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A recurring production mistake is adding convenience columns that answer interesting questions but are not essential to the first screen. That is a product problem masquerading as a technical problem.&lt;/p&gt;

&lt;p&gt;If a column requires complex relation loading and expensive conditional logic, ask a harder question: &lt;strong&gt;does this belong on the list page at all, or should it live on the detail page?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Admin UX is not improved by making every row a mini analytics dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Search and Sort Need Restraint
&lt;/h3&gt;

&lt;p&gt;Search and sort are especially dangerous because they look like low-risk improvements. They are not.&lt;/p&gt;

&lt;p&gt;A broad &lt;code&gt;-&amp;gt;searchable()&lt;/code&gt; on multiple relationship-backed columns can turn a fast screen into a heavy query generator. Sorting by related state can make the query planner miserable. Searching computed text is often a smell unless that value is persisted or denormalized intentionally.&lt;/p&gt;

&lt;p&gt;For most real admin panels, a better pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;search a small set of indexed primary fields&lt;/li&gt;
&lt;li&gt;use explicit filters for status, team, owner, or date ranges&lt;/li&gt;
&lt;li&gt;denormalize a display field when you know the list screen depends on it&lt;/li&gt;
&lt;li&gt;avoid pretending every interesting value should be globally searchable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That approach is less magical and more reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization Often Hurts More Than Teams Expect
&lt;/h2&gt;

&lt;p&gt;Laravel developers usually remember to profile SQL. They often forget to profile &lt;strong&gt;policies, gates, and visibility checks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Filament calls authorization in a lot of places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;page access&lt;/li&gt;
&lt;li&gt;navigation visibility&lt;/li&gt;
&lt;li&gt;row actions&lt;/li&gt;
&lt;li&gt;bulk actions&lt;/li&gt;
&lt;li&gt;form field visibility&lt;/li&gt;
&lt;li&gt;relation manager visibility&lt;/li&gt;
&lt;li&gt;action enable/disable state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your policy logic performs relationship queries or repeated membership checks, it can dominate a request even when the main table query is reasonable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Common Multi-Tenant Trap
&lt;/h3&gt;

&lt;p&gt;Here is a policy shape that is logically fine but dangerous on a busy Filament screen:&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;update&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;Project&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&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;teams&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;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;wherePivot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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;exists&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;If that policy is evaluated repeatedly for row actions across a table, the cost compounds quickly. The issue is not that policies are bad. The issue is that &lt;strong&gt;per-record authorization that re-queries shared context is wasteful&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Better options include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;preload the current tenant membership context once per request&lt;/li&gt;
&lt;li&gt;memoize cheap authorization state in the request lifecycle&lt;/li&gt;
&lt;li&gt;avoid duplicating the same expensive logic in &lt;code&gt;visible()&lt;/code&gt;, &lt;code&gt;disabled()&lt;/code&gt;, and policy checks together&lt;/li&gt;
&lt;li&gt;centralize team or tenant role resolution so it does not get rediscovered per row&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not mean you should bypass policies. It means you should make them cheap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Profile Authorization Like You Profile Queries
&lt;/h3&gt;

&lt;p&gt;A good local test is brutally simple: temporarily stub or simplify the suspicious policy path and compare request time. If the screen suddenly becomes fast, you found a policy bottleneck.&lt;/p&gt;

&lt;p&gt;Another useful tactic is request-scoped memoization for shared checks:&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;TeamMembershipService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="o"&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;isTeamAdmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$teamId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&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="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$teamId&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;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache&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="o"&gt;??=&lt;/span&gt; &lt;span class="no"&gt;\DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$teamId&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;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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;exists&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;Used carefully, that kind of request-local caching is a real fix because it eliminates redundant work without introducing stale cross-request state.&lt;/p&gt;

&lt;p&gt;The larger point is this: &lt;strong&gt;authorization logic is part of performance architecture&lt;/strong&gt;. In admin panels, it is not just a security concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relation Managers, Widgets, and Tabs Multiply Hidden Work
&lt;/h2&gt;

&lt;p&gt;When teams say a Filament edit page feels heavy, the form itself is often not the issue. The actual cost lives in the surrounding page chrome: relation managers, widgets, stat cards, tab badges, and counts sprinkled around the interface.&lt;/p&gt;

&lt;p&gt;This is the second big source of framework blame. The main page appears slow, so Filament gets accused. But the page is really acting like a compound dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Relation Managers Are Easy to Underestimate
&lt;/h3&gt;

&lt;p&gt;Each relation manager is its own data loader, table, action surface, and policy consumer. Add three of them to an edit page and the request lifecycle gets crowded fast.&lt;/p&gt;

&lt;p&gt;The problem gets worse when relation managers are visible by default even though the user only needs one at a time. If each manager performs counts, eager loads, and action authorization on first render, the page can feel sluggish before the user touches anything.&lt;/p&gt;

&lt;p&gt;When profiling an edit page, test with relation managers disabled one by one. That quickly reveals whether the form is slow or the surrounding components are slow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Widgets and Badge Counts Are Frequent Offenders
&lt;/h3&gt;

&lt;p&gt;Dashboards and resource pages love metrics. Counts feel cheap. They are often not.&lt;/p&gt;

&lt;p&gt;A common anti-pattern looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a widget counts overdue invoices&lt;/li&gt;
&lt;li&gt;a tab badge counts overdue invoices again&lt;/li&gt;
&lt;li&gt;a header stat computes the same number with slightly different constraints&lt;/li&gt;
&lt;li&gt;a relation manager tab recomputes another near-identical count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UI looks polished. The backend is doing duplicate work.&lt;/p&gt;

&lt;p&gt;If a metric appears in multiple places, centralize how it is loaded. Either compute it once in a dedicated query path or decide that not every surface needs a live count.&lt;/p&gt;

&lt;p&gt;This is where product discipline matters. Not every badge is worth a query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pagination Is Usually Better Than Table Maximalism
&lt;/h3&gt;

&lt;p&gt;Another quiet performance killer is oversized tables. Teams often assume that showing 100 rows by default is better admin UX than showing 25. In practice, that is usually wrong.&lt;/p&gt;

&lt;p&gt;A smaller, faster page with sharp filters is better than a large, sluggish page filled with decorative columns and computed state.&lt;/p&gt;

&lt;p&gt;If users complain they need more context per page, the first move should not be increasing pagination blindly. Try this order instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;remove low-value columns&lt;/li&gt;
&lt;li&gt;move expensive detail into the record page&lt;/li&gt;
&lt;li&gt;simplify row-level visual logic&lt;/li&gt;
&lt;li&gt;add better filters or presets&lt;/li&gt;
&lt;li&gt;only then consider raising rows per page&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sequence preserves speed and keeps the table useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Production-Sane Workflow for Filament Performance Testing
&lt;/h2&gt;

&lt;p&gt;Once you stop guessing, the work becomes much more straightforward. Most slow Filament screens can be improved with a disciplined pass through the same layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Order Actually Matters
&lt;/h3&gt;

&lt;p&gt;This is the sequence I would recommend for most Laravel teams:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;profile one screen and capture request time plus query count&lt;/li&gt;
&lt;li&gt;identify the slowest queries and run &lt;code&gt;EXPLAIN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;make the resource query explicit with &lt;code&gt;select()&lt;/code&gt;, narrow &lt;code&gt;with()&lt;/code&gt;, &lt;code&gt;withCount()&lt;/code&gt;, and &lt;code&gt;withExists()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;remove query work from column callbacks and per-row presentation logic&lt;/li&gt;
&lt;li&gt;inspect policies, action visibility rules, and repeated authorization checks&lt;/li&gt;
&lt;li&gt;isolate widgets, relation managers, tab badges, and duplicated metrics&lt;/li&gt;
&lt;li&gt;only after that, consider caching, Octane, or deeper infrastructure changes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That order saves time because it forces you to work from the hottest bottleneck outward.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Realistic Refactor Pattern
&lt;/h3&gt;

&lt;p&gt;Suppose a Filament resource index is slow because it shows customer data, invoice state, and order priority all in one table. A sensible refactor would usually look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;restrict the base select list&lt;/li&gt;
&lt;li&gt;eager load only the exact relationships needed for visible columns&lt;/li&gt;
&lt;li&gt;convert per-row &lt;code&gt;exists()&lt;/code&gt; checks into &lt;code&gt;withExists()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;replace free-form relationship search with targeted indexed filters&lt;/li&gt;
&lt;li&gt;trim low-value columns from the default table state&lt;/li&gt;
&lt;li&gt;lower the default page size if the screen is still doing too much&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not glamorous work. It is effective work.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Not to Do First
&lt;/h3&gt;

&lt;p&gt;A few things are usually the wrong first move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;adding broad caching before understanding query shape&lt;/li&gt;
&lt;li&gt;blaming Livewire for N+1 issues created in your table config&lt;/li&gt;
&lt;li&gt;increasing server resources before fixing obvious SQL waste&lt;/li&gt;
&lt;li&gt;collapsing everything into one giant index page because it feels convenient&lt;/li&gt;
&lt;li&gt;treating accessors as free when they hide expensive relation reads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those choices make the system harder to debug later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Decision Rule
&lt;/h2&gt;

&lt;p&gt;Filament is fast enough for serious Laravel applications. What it does not do is protect you from expensive Eloquent habits, sloppy authorization design, or overly ambitious admin tables.&lt;/p&gt;

&lt;p&gt;That is not a weakness. It is just the reality of building rich internal tools on top of expressive abstractions.&lt;/p&gt;

&lt;p&gt;If a screen is slow, your job is not to defend the framework or attack it. Your job is to locate the exact cost center. In most cases, you will find one of five things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the query loads too much data&lt;/li&gt;
&lt;li&gt;the table performs too much per-row work&lt;/li&gt;
&lt;li&gt;the policy layer repeats shared checks&lt;/li&gt;
&lt;li&gt;the page bundles too many widgets or relation managers&lt;/li&gt;
&lt;li&gt;the schema is missing indexes for how the admin UI actually queries data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Official docs worth keeping nearby while profiling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://filamentphp.com/docs" rel="noopener noreferrer"&gt;Filament Docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/eloquent-relationships" rel="noopener noreferrer"&gt;Laravel Eloquent Relationships&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/telescope" rel="noopener noreferrer"&gt;Laravel Telescope&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/queries" rel="noopener noreferrer"&gt;Laravel Query Builder&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule of thumb is simple and worth repeating: &lt;strong&gt;do not blame Filament until you can name the exact query, callback, policy, widget, or tab that is slow&lt;/strong&gt;. If you cannot name it, you have not tested performance yet. You have only felt it.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/filament-performance-testing-before-you-blame-framework/" rel="noopener noreferrer"&gt;https://qcode.in/filament-performance-testing-before-you-blame-framework/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>filament</category>
      <category>performance</category>
      <category>livewire</category>
    </item>
    <item>
      <title>Passwordless Laravel Auth Is Easy to Demo and Harder to Run Well</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Fri, 17 Jul 2026 05:08:37 +0000</pubDate>
      <link>https://dev.to/saqueib/passwordless-laravel-auth-is-easy-to-demo-and-harder-to-run-well-15fm</link>
      <guid>https://dev.to/saqueib/passwordless-laravel-auth-is-easy-to-demo-and-harder-to-run-well-15fm</guid>
      <description>&lt;p&gt;Passwordless auth sounds like a simplification until you try to run it in a real Laravel product. The UI gets simpler. The security model does not. You remove the password field, but you still have to prove identity, prevent replay, handle hostile email infrastructure, preserve decent UX across devices, and give support a way to debug failures without turning into a manual override team.&lt;/p&gt;

&lt;p&gt;That is why most magic-link examples are fine for prototypes and incomplete for production. They focus on generating a signed URL and calling &lt;code&gt;Auth::login()&lt;/code&gt; when it is opened. That is the easy part. The hard part is everything around the click.&lt;/p&gt;

&lt;p&gt;My recommendation is blunt: &lt;strong&gt;do not implement passwordless auth in Laravel as a “special login link” feature. Implement it as a short-lived authentication workflow with explicit state, single-use consumption, step-up hooks, and support visibility.&lt;/strong&gt; If you skip those layers, you are not shipping passwordless auth. You are shipping a bearer token in an email and hoping the rest works out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first threat is often your user's email security stack
&lt;/h2&gt;

&lt;p&gt;A lot of magic-link writeups quietly assume the first request to the link comes from the human who owns the inbox. In production, that assumption breaks fast.&lt;/p&gt;

&lt;p&gt;Enterprise email systems, secure mail gateways, antivirus products, browser preview services, and mobile mail clients often prefetch links. Some do it to scan for malware. Some do it to build previews. Some follow redirects. If your Laravel route performs the login on the first &lt;code&gt;GET&lt;/code&gt;, one of those automated actors can consume the credential before the user even sees the email.&lt;/p&gt;

&lt;p&gt;That creates a failure mode that feels bizarre to users. They click the email one minute later and see “link expired” or “token already used.” From their perspective, your auth is broken. From your perspective, it worked exactly once, just not for the person you intended.&lt;/p&gt;

&lt;p&gt;This is why &lt;strong&gt;magic-link login should almost never be consume-on-GET in a serious app&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use email links to resume a flow, not to finish it
&lt;/h3&gt;

&lt;p&gt;A better pattern is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User requests passwordless sign-in.&lt;/li&gt;
&lt;li&gt;You create a login attempt record in the database.&lt;/li&gt;
&lt;li&gt;You email a signed URL that identifies that attempt.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;GET&lt;/code&gt; request validates the link and renders a confirmation page.&lt;/li&gt;
&lt;li&gt;A deliberate &lt;code&gt;POST&lt;/code&gt; consumes the attempt and creates the session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That extra round-trip is not busywork. It is what separates “clickable email token” from “scanner-resistant auth flow.” Automated scanners are very good at issuing &lt;code&gt;GET&lt;/code&gt; requests. They are much worse at completing a CSRF-protected browser form in the same session context.&lt;/p&gt;

&lt;p&gt;A minimal route shape in Laravel can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'guest'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/login/passwordless'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RequestMagicLinkController&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="o"&gt;-&amp;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;'throttle:5,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;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passwordless.request'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/login/passwordless/{attempt}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ShowMagicLinkController&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="o"&gt;-&amp;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;'signed'&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;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passwordless.show'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/login/passwordless/{attempt}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ConsumeMagicLinkController&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="o"&gt;-&amp;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;'signed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:6,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;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passwordless.consume'&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 important design choice is not the route naming. It is the separation of concerns.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The email link proves the user reached the mailbox.&lt;/li&gt;
&lt;li&gt;The confirmation POST proves a browser session intentionally completed the flow.&lt;/li&gt;
&lt;li&gt;Your application decides whether that is enough for full access or only enough for provisional access.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Signed URLs are useful, but not sufficient
&lt;/h3&gt;

&lt;p&gt;Laravel's &lt;a href="https://laravel.com/docs/urls#signed-urls" rel="noopener noreferrer"&gt;signed URL support&lt;/a&gt; is absolutely worth using here. It protects query integrity and expiration. But it does not solve the full problem.&lt;/p&gt;

&lt;p&gt;A signed URL does &lt;strong&gt;not&lt;/strong&gt; make the underlying login attempt single-use. It does not tell you whether a link was consumed by a scanner. It does not stop a forwarded email from being reused inside its lifetime. It does not decide whether a new device should be trusted.&lt;/p&gt;

&lt;p&gt;That distinction matters because teams often mistake “cryptographically signed” for “production-ready.” Those are different claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core object is not the link. It is the login attempt
&lt;/h2&gt;

&lt;p&gt;If you want passwordless auth to survive production, you need a first-class persistence model for the flow. The link should point to a login attempt record, not carry the whole security story inside the URL.&lt;/p&gt;

&lt;p&gt;A reasonable schema usually includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;token_hash&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;consumed_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requested_ip&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requested_user_agent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requested_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;redirect_to&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;device_label&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;risk_flags&lt;/code&gt; or equivalent JSON metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The token itself should be random, short-lived, and stored only as a hash. Treat it the same way you treat reset tokens: the raw secret exists only long enough to email it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hash the token and keep the URL boring
&lt;/h3&gt;

&lt;p&gt;You do not need a clever token format. You need an unpredictable one.&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\Str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$plainToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MagicLoginAttempt&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_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;'token_hash'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$plainToken&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'expires_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="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;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'requested_ip'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;request&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;ip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'requested_user_agent'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;request&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;userAgent&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'redirect_to'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;temporarySignedRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'passwordless.show'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'attempt'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attempt&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;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$plainToken&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 is enough. Resist the urge to encode user data, device hints, or trust semantics into the token itself. Put state in the database where you can revoke it, inspect it, and reason about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  One active attempt or many?
&lt;/h3&gt;

&lt;p&gt;This is an architectural choice worth making explicitly.&lt;/p&gt;

&lt;p&gt;If a user requests five links in ten minutes, what should happen?&lt;/p&gt;

&lt;p&gt;The easy answer is to allow all of them until they expire. The operationally cleaner answer is usually to invalidate older pending attempts when a newer one is issued, especially for low-friction consumer flows. That reduces confusion and gives support a clearer story: “only the latest email works.”&lt;/p&gt;

&lt;p&gt;For B2B apps, I prefer one of these two policies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single active attempt per user&lt;/strong&gt; for standard sign-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoped active attempts&lt;/strong&gt; when you want parallel flows, such as one for web login and one for privileged action confirmation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I would avoid is laissez-faire token issuance with overlapping validity windows. That produces the exact support tickets you do not want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay protection is the part that separates demos from systems
&lt;/h2&gt;

&lt;p&gt;A magic link is a bearer credential. That means whoever presents it first within the allowed window may gain access unless you design the consumption step carefully.&lt;/p&gt;

&lt;p&gt;The minimum bar is straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the token must be single-use&lt;/li&gt;
&lt;li&gt;token consumption must be atomic&lt;/li&gt;
&lt;li&gt;successful login must rotate the session&lt;/li&gt;
&lt;li&gt;second use must fail cleanly, not race unpredictably&lt;/li&gt;
&lt;li&gt;old attempts should not remain ambiguously “sort of valid”&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Consume with an atomic state transition
&lt;/h3&gt;

&lt;p&gt;The most common bug in homemade passwordless auth is loading a valid attempt, checking it, and then updating it later in a way that allows two near-simultaneous requests to succeed.&lt;/p&gt;

&lt;p&gt;The right mental model is not “validate then login.” It is “win the one-time state transition, then login.”&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$attemptId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MagicLoginAttempt&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;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attemptId&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;'expires_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;firstOrFail&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;token_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'token'&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="nv"&gt;$updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MagicLoginAttempt&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;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attempt&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="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;'consumed_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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'expires_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'consumed_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="s1"&gt;'consumed_ip'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;ip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'consumed_user_agent'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;userAgent&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="mi"&gt;1000&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_if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$updated&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'This sign-in link is no longer valid.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;loginUsingId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remember&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;session&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;regenerate&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;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PostMagicLoginRedirector&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attempt&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 update call is doing the real work. Only one request gets to transition the attempt from pending to consumed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not overfit to IP and user agent
&lt;/h3&gt;

&lt;p&gt;A lot of teams see replay risk and immediately try to bind the flow to IP address or browser fingerprint. That sounds strong until real users hit it from mobile networks, privacy-preserving browsers, VPNs, or email apps that hand off to a different browser instance.&lt;/p&gt;

&lt;p&gt;My advice is to treat those signals as &lt;strong&gt;risk indicators, not absolute truth&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Good uses of those signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;require extra confirmation if the consume request is materially different from the request that created the attempt&lt;/li&gt;
&lt;li&gt;downgrade trust for the resulting session&lt;/li&gt;
&lt;li&gt;add audit metadata&lt;/li&gt;
&lt;li&gt;trigger step-up auth for sensitive accounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bad uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hard-block normal device transitions&lt;/li&gt;
&lt;li&gt;permanently deny login because the IP changed between request and click&lt;/li&gt;
&lt;li&gt;pretend browser fingerprints are stable enough to be identity proof&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Passwordless auth gets worse, not better, when you replace passwords with brittle environmental checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expiration should reflect attack window, not developer convenience
&lt;/h3&gt;

&lt;p&gt;Fifteen minutes is a common default because it feels reasonable, and for many apps it is. But do not pick a TTL because it appears in tutorials. Pick it because it matches the risk of the action.&lt;/p&gt;

&lt;p&gt;A low-privilege product login can often tolerate a short-lived email token. A billing confirmation or admin access flow should usually have a narrower window and stronger follow-up checks.&lt;/p&gt;

&lt;p&gt;The right question is not “what expiry do other apps use?” It is “how much damage can a leaked email link do before it expires?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Device trust and step-up auth are where the architecture gets real
&lt;/h2&gt;

&lt;p&gt;A lot of teams imagine passwordless as a replacement for the entire auth stack. That is the wrong framing for any app with meaningful account value.&lt;/p&gt;

&lt;p&gt;In practice, passwordless auth is usually your &lt;strong&gt;primary login factor&lt;/strong&gt;, not your entire authorization model. Once the user proves inbox access, you still need a policy for new devices, risky sessions, privileged actions, and recovery.&lt;/p&gt;

&lt;p&gt;This is where many Laravel apps should stop improvising and let &lt;strong&gt;Fortify&lt;/strong&gt; handle the second-factor layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Magic link first, Fortify second
&lt;/h3&gt;

&lt;p&gt;If you already use &lt;a href="https://laravel.com/docs/fortify" rel="noopener noreferrer"&gt;Laravel Fortify&lt;/a&gt;, the clean approach is to keep concerns separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;passwordless flow establishes baseline identity&lt;/li&gt;
&lt;li&gt;Fortify handles TOTP or other second-factor challenge when required&lt;/li&gt;
&lt;li&gt;trusted-device state influences whether challenge can be skipped&lt;/li&gt;
&lt;li&gt;authorization gates for sensitive actions still check current assurance level&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That design matters because it lets you avoid a common anti-pattern: stuffing every security decision into the magic-link controller.&lt;/p&gt;

&lt;p&gt;The magic-link flow should answer one question: &lt;em&gt;did this user complete a valid email-based sign-in attempt?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fortify and your trust policy should answer the next question: &lt;em&gt;is this session strong enough for the thing they are trying to do?&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Provisional sessions are underrated
&lt;/h3&gt;

&lt;p&gt;A clean way to integrate the handoff is to create a real authenticated session after successful token consumption, but mark it as &lt;strong&gt;provisional&lt;/strong&gt; until required step-up checks pass.&lt;/p&gt;

&lt;p&gt;For example, after &lt;code&gt;Auth::loginUsingId()&lt;/code&gt;, you can set session metadata like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;auth_method=passwordless_email&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auth_assurance=low&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;step_up_required=true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;trusted_device=false&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then middleware or your redirect layer can decide whether the user goes straight to the dashboard, to the Fortify challenge, or to a limited-access screen.&lt;/p&gt;

&lt;p&gt;That gives you a lot more control than treating auth as a binary “logged in or not” state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trusted device should be revocable server state
&lt;/h3&gt;

&lt;p&gt;The lazy version of trusted device is a permanent cookie that says “do not ask again.” That is convenient and weak.&lt;/p&gt;

&lt;p&gt;A better version is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a random device token stored as a hashed record server-side&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;expires_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a label the user can understand, like “MacBook Pro, Chrome”&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;last_seen_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a way to revoke all or one device from account settings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That lets you preserve smooth repeat sign-in without making trust irreversible.&lt;/p&gt;

&lt;p&gt;The policy should also be narrower than most teams first imagine. Trusted device should usually reduce friction for routine access. It should &lt;strong&gt;not&lt;/strong&gt; silently authorize sensitive actions forever.&lt;/p&gt;

&lt;p&gt;A practical policy might look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ordinary product access: allow with passwordless login&lt;/li&gt;
&lt;li&gt;new device on admin account: require Fortify challenge&lt;/li&gt;
&lt;li&gt;billing change or API key creation: require recent step-up regardless of trusted device&lt;/li&gt;
&lt;li&gt;team ownership transfer: require fresh challenge, always&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the difference between “passwordless login” and “passwordless security theater.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Support and observability determine whether the system survives launch
&lt;/h2&gt;

&lt;p&gt;The first time passwordless auth fails, support will learn more about your design than your developers did.&lt;/p&gt;

&lt;p&gt;Users do not file tickets that say “I suspect your token consumption semantics are vulnerable to prefetch races.” They say things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“the link did not work”&lt;/li&gt;
&lt;li&gt;“I got logged out and the new email also failed”&lt;/li&gt;
&lt;li&gt;“it says already used”&lt;/li&gt;
&lt;li&gt;“I’m on a new laptop and now I’m stuck”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your team cannot reconstruct the flow quickly, they will start inventing manual fixes. That is where insecure operational habits are born.&lt;/p&gt;

&lt;h3&gt;
  
  
  Log events across the lifecycle
&lt;/h3&gt;

&lt;p&gt;At minimum, emit structured events for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;login attempt created&lt;/li&gt;
&lt;li&gt;email dispatch attempted&lt;/li&gt;
&lt;li&gt;email dispatch succeeded or failed&lt;/li&gt;
&lt;li&gt;landing route viewed&lt;/li&gt;
&lt;li&gt;consume requested&lt;/li&gt;
&lt;li&gt;consume rejected with reason&lt;/li&gt;
&lt;li&gt;session created&lt;/li&gt;
&lt;li&gt;Fortify challenge initiated&lt;/li&gt;
&lt;li&gt;challenge completed or failed&lt;/li&gt;
&lt;li&gt;trusted device granted, refreshed, revoked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The events do not need to be fancy. They need to be correlated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;MagicLinkRequested&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;$attempt&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="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="nc"&gt;MagicLinkEmailSent&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;$attempt&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="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="nc"&gt;MagicLinkViewed&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;$attempt&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="nf"&gt;request&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;ip&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;MagicLinkConsumeRejected&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;$attempt&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;'expired'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;MagicLinkConsumed&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;$attempt&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="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="nc"&gt;StepUpChallengeRequired&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;$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="nf"&gt;session&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;getId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;TrustedDeviceGranted&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;$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="nv"&gt;$device&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What matters is that a support engineer or developer can inspect a timeline and answer basic questions without guessing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build for common failure stories, not just malicious ones
&lt;/h3&gt;

&lt;p&gt;The most frequent problems are usually operational, not adversarial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user clicked the oldest of several emails&lt;/li&gt;
&lt;li&gt;the corporate mail gateway prefetched the link&lt;/li&gt;
&lt;li&gt;the user opened the email on mobile but expected the desktop browser to be logged in&lt;/li&gt;
&lt;li&gt;the device changed and step-up auth was required unexpectedly&lt;/li&gt;
&lt;li&gt;the email delivery lag exceeded the token TTL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your system should be opinionated about these cases.&lt;/p&gt;

&lt;p&gt;For example, if you detect that an already-consumed attempt was first viewed by a mail-security user agent before the real browser arrived, your UI can say something useful instead of a generic failure message: “Your email security scanner may have opened this sign-in link. Request a new one.”&lt;/p&gt;

&lt;p&gt;That is not just better UX. It reduces pointless support volume.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recovery policy is part of the auth design
&lt;/h3&gt;

&lt;p&gt;Passwordless auth fails hardest when inbox access is degraded or lost. If the user's email is unavailable and their trusted device is gone, what happens next?&lt;/p&gt;

&lt;p&gt;You need that answer before launch.&lt;/p&gt;

&lt;p&gt;Good recovery design usually includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a clear operator policy for what support may and may not do&lt;/li&gt;
&lt;li&gt;audited device revocation and session revocation&lt;/li&gt;
&lt;li&gt;strong identity proof requirements before account recovery&lt;/li&gt;
&lt;li&gt;explicit separation between “resend login flow” and “override security controls”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I would avoid is first-line support having the power to casually disable two-factor settings or impersonate users. That turns your help desk into the weakest part of the auth stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would actually ship in a Laravel app
&lt;/h2&gt;

&lt;p&gt;If I were replacing passwords in a real Laravel product today, I would keep the design deliberately boring.&lt;/p&gt;

&lt;p&gt;The implementation would have these properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;email links resume a flow on &lt;code&gt;GET&lt;/code&gt;; they do not log users in directly&lt;/li&gt;
&lt;li&gt;login attempts are stored server-side with hashed tokens and explicit lifecycle fields&lt;/li&gt;
&lt;li&gt;only the latest standard sign-in attempt remains valid unless there is a clear reason otherwise&lt;/li&gt;
&lt;li&gt;token consumption happens on &lt;code&gt;POST&lt;/code&gt; with CSRF protection and atomic single-use update&lt;/li&gt;
&lt;li&gt;successful consumption regenerates the session immediately&lt;/li&gt;
&lt;li&gt;the resulting session carries an assurance level, not just a yes/no login state&lt;/li&gt;
&lt;li&gt;Fortify handles step-up auth instead of homemade challenge logic scattered across controllers&lt;/li&gt;
&lt;li&gt;trusted-device records are revocable, expiring, and backed by server state&lt;/li&gt;
&lt;li&gt;sensitive actions require recent assurance, not just any authenticated session&lt;/li&gt;
&lt;li&gt;support can inspect the full event trail without ever seeing raw tokens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not the shortest path to “passwordless.” It is the shortest path I would trust.&lt;/p&gt;

&lt;p&gt;Magic links are valuable. They remove password reset churn, reduce credential-stuffing exposure, and often improve the first-login experience. Those are real wins. But they are only wins if the surrounding system is tighter than the password flow you replaced.&lt;/p&gt;

&lt;p&gt;The decision rule is simple: &lt;strong&gt;if your Laravel passwordless auth can be consumed by a scanner, replayed by a second request, trusted forever on the wrong device, or debugged only by guesswork, you have not finished the design.&lt;/strong&gt; The magic link is not the hard part. The operational model is.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/passwordless-laravel-auth-magic-links-are-not-the-hard-part/" rel="noopener noreferrer"&gt;https://qcode.in/passwordless-laravel-auth-magic-links-are-not-the-hard-part/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>authentication</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Testing Laravel AI features without burning API credits</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Tue, 14 Jul 2026 05:48:24 +0000</pubDate>
      <link>https://dev.to/saqueib/testing-laravel-ai-features-without-burning-api-credits-4eaa</link>
      <guid>https://dev.to/saqueib/testing-laravel-ai-features-without-burning-api-credits-4eaa</guid>
      <description>&lt;p&gt;The expensive part of testing AI features is not the API bill. It is the false confidence. If your Laravel tests only prove that a controller returns &lt;code&gt;200&lt;/code&gt;, you are not testing AI behavior. You are testing that your app can make a network request.&lt;/p&gt;

&lt;p&gt;The fix is simple: &lt;strong&gt;treat the model like an external dependency and test your AI layer as a deterministic contract&lt;/strong&gt;. That means faking model output, faking tool-call payloads, simulating streaming chunks, and forcing ugly failures on purpose.&lt;/p&gt;

&lt;p&gt;This is cheaper, but more importantly, it makes AI behavior reviewable before production. That is the real win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a boundary around the model first
&lt;/h2&gt;

&lt;p&gt;If your controller, job, or Livewire component talks to OpenAI directly, your tests get brittle fast. You end up asserting on transport details, not product behavior.&lt;/p&gt;

&lt;p&gt;A better shape is to put your AI integration behind one application service. That service can return a small, app-specific result object like &lt;code&gt;AiReply&lt;/code&gt;, &lt;code&gt;DraftResult&lt;/code&gt;, or &lt;code&gt;SupportAnswer&lt;/code&gt;. Your controllers test app behavior. A narrower set of tests covers the provider integration.&lt;/p&gt;

&lt;p&gt;That boundary matters because your fake data should match &lt;strong&gt;your contract&lt;/strong&gt;, not the provider's entire payload. Providers change fields. Your app should not care unless the change affects behavior.&lt;/p&gt;

&lt;p&gt;Here is a small example:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Services\Ai&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductCopyService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;OpenAiResponsesClient&lt;/span&gt; &lt;span class="nv"&gt;$client&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$audience&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ProductCopyResult&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-5.4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'input'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Write concise product copy for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; aimed at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$audience&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProductCopyResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;headline&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;data_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'output_text'&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="n"&gt;tokensUsed&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;data_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'usage.total_tokens'&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="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;Now your app tests do not need to know every provider field. They only need to know what a valid &lt;code&gt;ProductCopyResult&lt;/code&gt; looks like.&lt;/p&gt;

&lt;p&gt;If you are using Laravel's AI SDK, the same principle still applies. Keep your app-facing behavior behind a boundary and fake at the transport or client seam. Laravel gives you strong testing primitives for this pattern through the &lt;a href="https://laravel.com/docs/13.x/http-client" rel="noopener noreferrer"&gt;HTTP client fake tools&lt;/a&gt; and general &lt;a href="https://laravel.com/docs/13.x/testing" rel="noopener noreferrer"&gt;testing utilities&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fake model responses, not just success codes
&lt;/h2&gt;

&lt;p&gt;Most teams stop too early. They fake a &lt;code&gt;200 OK&lt;/code&gt; with some text and call it done. That only covers the happy path where the model behaves exactly how you hoped.&lt;/p&gt;

&lt;p&gt;You want at least three classes of fake responses:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A valid answer your app should accept.&lt;/li&gt;
&lt;li&gt;A structurally valid answer your app should reject or normalize.&lt;/li&gt;
&lt;li&gt;A provider error your app should handle cleanly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For direct HTTP integrations, &lt;code&gt;Http::fake()&lt;/code&gt; is enough. The important part is the payload shape. Make the fake look like the real provider format your parser expects.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;Pest\Laravel\postJson&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'stores generated copy without calling the real API'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'api.openai.com/*'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'output_text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Ship faster with a Laravel-first AI workflow.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'usage'&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;'total_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;148&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/products/copy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'QCode Deploy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'audience'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Laravel teams'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertJsonPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'headline'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Ship faster with a Laravel-first AI workflow.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertSent&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;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;url&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;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/v1/responses'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'gpt-5.4'&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 last assertion matters. It proves your code sent the request you think it sent, without leaking the test into provider internals.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test malformed-but-plausible output
&lt;/h3&gt;

&lt;p&gt;AI failures are usually not crashes. They are subtly wrong outputs that still look reasonable.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON returned as text inside Markdown fences.&lt;/li&gt;
&lt;li&gt;Missing fields in a structured payload.&lt;/li&gt;
&lt;li&gt;Tool-call arguments that are syntactically valid but semantically wrong.&lt;/li&gt;
&lt;li&gt;Overlong output that should trigger truncation or validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are the tests that save you from production cleanup jobs.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rejects product copy when the model returns empty content'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'api.openai.com/*'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'output_text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'usage'&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;'total_tokens'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;83&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/products/copy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'QCode Deploy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'audience'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Laravel teams'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&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;assertJsonPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'AI returned an unusable result.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the mindset shift: &lt;strong&gt;test whether your code can judge model output&lt;/strong&gt;, not whether the model is smart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool calls need contract tests, not vibes
&lt;/h2&gt;

&lt;p&gt;Once your feature uses tool calling, naive tests break down. You are no longer asserting against one blob of output. You are testing a mini workflow: model asks for a tool, your app executes it, and the final answer depends on that result.&lt;/p&gt;

&lt;p&gt;If you are on OpenAI's current API shape, the &lt;a href="https://developers.openai.com/api/reference/responses/overview/" rel="noopener noreferrer"&gt;Responses API&lt;/a&gt; and its &lt;a href="https://developers.openai.com/api/docs/guides/function-calling" rel="noopener noreferrer"&gt;function-calling guide&lt;/a&gt; make this explicit. Tool calls and tool outputs are separate items, correlated by &lt;code&gt;call_id&lt;/code&gt;. That detail is worth testing because bad correlation bugs are easy to miss in manual demos.&lt;/p&gt;

&lt;p&gt;A good pattern is to keep fixtures tiny and intentional. Do not dump entire provider payloads into your test unless you need them. Include only the fields your parsing logic actually uses.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'executes the stock lookup tool and returns the final answer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fakeSequence&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;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'output'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;
                &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'function_call'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'call_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'call_123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'lookupInventory'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'arguments'&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="s1"&gt;'sku'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'QC-DEPLOY'&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="mi"&gt;200&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;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'output_text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'QCode Deploy is in stock and ships this week.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mockery&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InventoryLookup&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$tool&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;shouldReceive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'forSku'&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;once&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;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'QC-DEPLOY'&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;andReturn&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'in_stock'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'eta'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'this week'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InventoryLookup&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tool&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/support/ask'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'question'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Can I get QCode Deploy this week?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertJsonPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'answer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'QCode Deploy is in stock and ships this week.'&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;What should you assert here?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The tool was called exactly once.&lt;/li&gt;
&lt;li&gt;The parsed arguments match your expected schema.&lt;/li&gt;
&lt;li&gt;The final user-facing answer reflects tool output, not hallucinated data.&lt;/li&gt;
&lt;li&gt;Unexpected tool names or invalid arguments are handled safely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Failure modes worth forcing
&lt;/h3&gt;

&lt;p&gt;Tool calling tends to fail in boring, expensive ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model requests a tool that no longer exists.&lt;/li&gt;
&lt;li&gt;The arguments are missing required keys.&lt;/li&gt;
&lt;li&gt;The tool succeeds, but returns data your final formatter cannot handle.&lt;/li&gt;
&lt;li&gt;The model loops and keeps asking for the same tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not edge cases. They are normal production states. If you do not have tests for them, your AI feature is still a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming tests should validate transcript flow
&lt;/h2&gt;

&lt;p&gt;Streaming is where many Laravel AI tests become theater. Teams assert that a stream started, maybe that the response status was fine, and move on. That misses the real risk: partial content, broken event order, dropped tool updates, and cleanup logic that never runs.&lt;/p&gt;

&lt;p&gt;The right mental model is to test &lt;strong&gt;transcript flow&lt;/strong&gt;, not socket magic.&lt;/p&gt;

&lt;p&gt;OpenAI's current &lt;a href="https://developers.openai.com/api/docs/guides/streaming-responses" rel="noopener noreferrer"&gt;streaming docs&lt;/a&gt; describe semantic event types rather than one opaque stream blob. Laravel's newer AI and response tooling also leans into streaming as a first-class pattern. Your tests should reflect that by asserting against emitted chunks or normalized events.&lt;/p&gt;

&lt;p&gt;One clean approach is to normalize incoming stream events into an internal DTO before they hit the UI. Then your tests can fake a short event sequence and assert on the rendered transcript.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'streams partial output in order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$events&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="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'response.created'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'response.output_text.delta'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delta'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Ship'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'response.output_text.delta'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delta'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;' faster'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'response.output_text.delta'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delta'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;' with Laravel.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'response.completed'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="nv"&gt;$stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FakeAiEventStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$events&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;iterator_to_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StreamedAnswerFormatter&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toClientChunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunks&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;toBe&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'Ship'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;' faster'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;' with Laravel.'&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;This looks almost too simple, which is a good sign. Streaming tests should not require a real SSE connection to be useful. They should prove three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;chunks arrive in the expected order&lt;/li&gt;
&lt;li&gt;partial state is accumulated correctly&lt;/li&gt;
&lt;li&gt;terminal events trigger cleanup or persistence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your app stores transcript messages after stream completion, assert that explicitly. If cancellation should avoid persistence, write that test too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not skip interrupted streams
&lt;/h3&gt;

&lt;p&gt;Interrupted streams are where production bugs hide. Simulate a stream that ends after two deltas and never emits completion. Your app should not mark that run as successful. It should either retry, surface a recoverable error, or store an incomplete state intentionally.&lt;/p&gt;

&lt;p&gt;That is the difference between a polished AI feature and a support ticket generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Force ugly failures on purpose
&lt;/h2&gt;

&lt;p&gt;You should spend more time testing failure states than model brilliance. AI providers fail in ordinary infrastructure ways and weird AI-specific ways.&lt;/p&gt;

&lt;p&gt;The minimum failure set I want in a Laravel codebase is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timeout&lt;/li&gt;
&lt;li&gt;rate limit&lt;/li&gt;
&lt;li&gt;provider &lt;code&gt;500&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;malformed JSON or missing fields&lt;/li&gt;
&lt;li&gt;empty output&lt;/li&gt;
&lt;li&gt;tool-call validation failure&lt;/li&gt;
&lt;li&gt;stream interruption&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Laravel's fake HTTP layer makes most of this straightforward. Use fake sequences when retries matter. Use exceptions when transport failure matters more than response parsing.&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\Http\Client\ConnectionException&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\Support\Facades\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'falls back gracefully when the provider times out'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ConnectionException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Timed out contacting AI provider.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/support/ask'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'question'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Summarize this order issue'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&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;assertJsonPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'AI is temporarily unavailable.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'retries once after a rate limit response'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fakeSequence&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;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'error'&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;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Rate limit'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="mi"&gt;429&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;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'output_text'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Final answer after retry.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/support/ask'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'question'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Retry example'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$response&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertJsonPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'answer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Final answer after retry.'&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;These tests are not glamorous, but they are where trust comes from. Anyone can demo a good model response. Fewer teams can prove their app behaves well when the provider is slow, inconsistent, or partially broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make AI behavior reviewable
&lt;/h2&gt;

&lt;p&gt;Saving credits is useful. Making AI changes reviewable is what actually improves engineering quality.&lt;/p&gt;

&lt;p&gt;A solid team workflow usually looks like this:&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep canonical fixtures small
&lt;/h3&gt;

&lt;p&gt;Use a few realistic payload fixtures for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;plain text success&lt;/li&gt;
&lt;li&gt;structured output success&lt;/li&gt;
&lt;li&gt;tool-call round trip&lt;/li&gt;
&lt;li&gt;streaming transcript&lt;/li&gt;
&lt;li&gt;provider failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not store giant provider dumps unless a parsing bug requires them. Small fixtures are readable in pull requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Assert on normalized outputs
&lt;/h3&gt;

&lt;p&gt;Your tests should mostly assert on app-level results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rendered answer&lt;/li&gt;
&lt;li&gt;saved database record&lt;/li&gt;
&lt;li&gt;emitted event&lt;/li&gt;
&lt;li&gt;retry path&lt;/li&gt;
&lt;li&gt;fallback message&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That keeps tests stable even if you switch providers later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snapshot only after normalization
&lt;/h3&gt;

&lt;p&gt;If you use snapshots, snapshot your own DTO or trimmed JSON shape, not the raw provider payload. Raw payload snapshots turn into noise fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep one or two live integration tests
&lt;/h3&gt;

&lt;p&gt;I still want a tiny number of opt-in tests that hit the real provider, usually outside the default CI path. Not many. Just enough to catch authentication drift, model deprecations, or a broken request shape.&lt;/p&gt;

&lt;p&gt;But your day-to-day suite should run with zero credits and near-zero randomness.&lt;/p&gt;

&lt;p&gt;That is the rule of thumb:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit and feature tests should prove your application can handle AI deterministically. Real API calls should be rare verification, not the foundation of confidence.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your Laravel AI tests still depend on a live model to feel meaningful, the problem is not your budget. The problem is that your AI boundary is too loose.&lt;/p&gt;

&lt;p&gt;Tighten the boundary, fake the right layers, and test the awkward cases first. That is how you ship AI features without burning credits or trust.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/how-to-test-laravel-ai-features-without-burning-api-credits/" rel="noopener noreferrer"&gt;https://qcode.in/how-to-test-laravel-ai-features-without-burning-api-credits/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>testing</category>
      <category>openai</category>
    </item>
    <item>
      <title>PHP Attributes Can Help Your Docs, But They Won’t Save Them</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Sun, 12 Jul 2026 07:25:33 +0000</pubDate>
      <link>https://dev.to/saqueib/php-attributes-can-help-your-docs-but-they-wont-save-them-5bnn</link>
      <guid>https://dev.to/saqueib/php-attributes-can-help-your-docs-but-they-wont-save-them-5bnn</guid>
      <description>&lt;p&gt;PHP attributes are a useful documentation tool, but they are a terrible documentation strategy on their own. That is the short version.&lt;/p&gt;

&lt;p&gt;The appeal is obvious. Attributes live next to the code. They are structured, machine-readable, and hard to ignore during implementation. For Laravel and PHP teams building APIs, policies, commands, or internal frameworks, that sounds like the perfect answer to documentation drift.&lt;/p&gt;

&lt;p&gt;It is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attributes are excellent for capturing metadata close to the code. They are weak at explaining intent, tradeoffs, workflow, and reader context.&lt;/strong&gt; If you treat them as the whole documentation system, you usually end up with reference output that is technically populated but editorially useless.&lt;/p&gt;

&lt;p&gt;My recommendation is simple: use attributes as a &lt;strong&gt;source layer&lt;/strong&gt;, not as the finished docs. They are best when they feed a stronger documentation system that still has structure, narrative, and human judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What attributes are actually good at
&lt;/h2&gt;

&lt;p&gt;PHP attributes were built for structured metadata, not prose. The PHP manual describes them as machine-readable metadata attached to classes, methods, properties, parameters, and more, accessible through reflection: &lt;a href="https://www.php.net/manual/en/language.attributes.overview.php" rel="noopener noreferrer"&gt;PHP attributes overview&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That makes them very good at things code already knows for sure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;route-level metadata&lt;/li&gt;
&lt;li&gt;validation or schema hints&lt;/li&gt;
&lt;li&gt;authorization intent&lt;/li&gt;
&lt;li&gt;serialization rules&lt;/li&gt;
&lt;li&gt;OpenAPI field and response definitions&lt;/li&gt;
&lt;li&gt;command signatures or handler registration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, attributes shine when the documentation value is &lt;strong&gt;declarative and local&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A basic example looks clean for a reason:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;OpenApi\Attributes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="no"&gt;OA&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\Http\Resources\UserResource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Http\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;OA\Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/api/users/{user}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Fetch a single user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;:&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;span class="na"&gt;#[OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))]&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;OA\Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'User returned successfully'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OA\JsonContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UserResource&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="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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&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;span class="c1"&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 is a good use of attributes because the metadata is tightly coupled to the code surface. The route shape, parameter location, and basic response contract belong close to the handler. If the endpoint changes, the attribute should change with it.&lt;/p&gt;

&lt;p&gt;This is also why tools like &lt;strong&gt;swagger-php&lt;/strong&gt; lean hard into attributes now. Its docs treat attributes as a first-class documentation path, and its annotation model is already moving toward deprecation in favor of attributes: &lt;a href="https://zircote.com/swagger-php/" rel="noopener noreferrer"&gt;swagger-php&lt;/a&gt; and &lt;a href="https://zircote.com/swagger-php/reference/attributes.html" rel="noopener noreferrer"&gt;attribute reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the pro-attribute case is real. They reduce one kind of drift: the drift between implementation details and machine-readable reference data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where attribute-driven docs become a trap
&lt;/h2&gt;

&lt;p&gt;The problem starts when teams confuse &lt;strong&gt;structured metadata&lt;/strong&gt; with &lt;strong&gt;useful documentation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A generated reference can be technically complete and still fail its reader.&lt;/p&gt;

&lt;p&gt;That usually happens in three predictable ways.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The docs become local but not meaningful
&lt;/h3&gt;

&lt;p&gt;Attributes are great at saying &lt;em&gt;what exists&lt;/em&gt;. They are bad at saying &lt;em&gt;why it matters&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A generated API page may tell you an endpoint accepts &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;page&lt;/code&gt;, and &lt;code&gt;sort&lt;/code&gt;, and still never answer the questions developers actually care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which filters are stable API contracts versus convenience helpers?&lt;/li&gt;
&lt;li&gt;What combinations are slow or discouraged?&lt;/li&gt;
&lt;li&gt;What defaults are business-critical?&lt;/li&gt;
&lt;li&gt;What failure cases should clients design around?&lt;/li&gt;
&lt;li&gt;When should this endpoint not be used at all?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Attributes do not naturally answer those questions because those answers are not local facts. They are editorial explanations.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The reference starts lying through omission
&lt;/h3&gt;

&lt;p&gt;Teams often trust generated docs too much because they look official. But a clean generated page can hide serious gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;examples are missing&lt;/li&gt;
&lt;li&gt;response semantics are underspecified&lt;/li&gt;
&lt;li&gt;auth behavior is implied, not stated&lt;/li&gt;
&lt;li&gt;pagination edge cases are absent&lt;/li&gt;
&lt;li&gt;business rules live in service code, not in the documented contract&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the dangerous version of drift. The docs are not obviously stale. They are &lt;strong&gt;plausibly incomplete&lt;/strong&gt;, which is worse because people trust them longer.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Attributes accumulate faster than they get curated
&lt;/h3&gt;

&lt;p&gt;Attributes feel cheap to add, so teams add them everywhere. That is fine at first. Then the codebase gets noisy.&lt;/p&gt;

&lt;p&gt;A controller method ends up carrying routing metadata, OpenAPI metadata, security metadata, response metadata, and internal framework metadata all in one stack. At that point, you did not create self-documenting code. You created a metadata wall.&lt;/p&gt;

&lt;p&gt;That wall has two costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;developers stop reading it carefully&lt;/li&gt;
&lt;li&gt;the generated docs inherit the same lack of focus&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output may still validate. That does not mean it communicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real comparison: attributes versus editorial docs
&lt;/h2&gt;

&lt;p&gt;The useful comparison is not attributes versus no attributes. It is &lt;strong&gt;attributes-only docs&lt;/strong&gt; versus &lt;strong&gt;attributes feeding an editorial documentation layer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Attributes-only documentation wins on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;proximity to code&lt;/li&gt;
&lt;li&gt;machine readability&lt;/li&gt;
&lt;li&gt;generation speed&lt;/li&gt;
&lt;li&gt;lower risk of obvious schema drift&lt;/li&gt;
&lt;li&gt;better tooling automation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Editorial documentation wins on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;decision-making context&lt;/li&gt;
&lt;li&gt;examples with intent&lt;/li&gt;
&lt;li&gt;warning readers about failure modes&lt;/li&gt;
&lt;li&gt;explaining patterns across endpoints or modules&lt;/li&gt;
&lt;li&gt;helping new developers understand how the system is supposed to be used&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why attributes are a shortcut and a drift trap at the same time.&lt;/p&gt;

&lt;p&gt;If you stay purely manual, the docs drift because humans forget. If you stay purely attribute-driven, the docs drift because the generated output captures only the pieces the generator can see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The best systems split the job.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Attributes own structured truth that should stay near code.&lt;/li&gt;
&lt;li&gt;Generated references expose that truth consistently.&lt;/li&gt;
&lt;li&gt;Editorial docs explain how to use the system well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same pattern that works in Laravel apps generally. Route definitions are not architecture docs. Validation rules are not onboarding docs. Resource classes are not product guidance. They are inputs into a bigger understanding.&lt;/p&gt;

&lt;p&gt;Documentation should be designed the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where attributes help most in Laravel codebases
&lt;/h2&gt;

&lt;p&gt;Laravel teams get the best results from attributes when they use them on &lt;strong&gt;contract surfaces&lt;/strong&gt;, not as a universal writing system.&lt;/p&gt;

&lt;p&gt;Good targets include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API endpoint metadata&lt;/li&gt;
&lt;li&gt;request and response schema mapping&lt;/li&gt;
&lt;li&gt;policy or permission registration&lt;/li&gt;
&lt;li&gt;event, listener, or command discovery&lt;/li&gt;
&lt;li&gt;internal package hooks where reflection already exists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bad targets are usually the places where explanation matters more than declaration.&lt;/p&gt;

&lt;p&gt;For example, this is a reasonable attribute shape because it captures local truth:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;#[RequiresPermission('users.view')]&lt;/span&gt;
&lt;span class="na"&gt;#[AuditAction('user.viewed')]&lt;/span&gt;
&lt;span class="na"&gt;#[CacheTtl(seconds: 60)]&lt;/span&gt;
&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ShowUserAction&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;__invoke&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;UserData&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those attributes tell your framework or tooling something concrete. They can also be surfaced in generated internal reference material.&lt;/p&gt;

&lt;p&gt;But they still do not answer bigger questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why is &lt;code&gt;users.view&lt;/code&gt; separated from &lt;code&gt;users.list&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Why is the cache only 60 seconds?&lt;/li&gt;
&lt;li&gt;What user states are intentionally hidden?&lt;/li&gt;
&lt;li&gt;What audit guarantees should downstream systems expect?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those answers belong in docs written for humans, not in ever-growing attribute payloads.&lt;/p&gt;

&lt;h3&gt;
  
  
  A useful rule of thumb
&lt;/h3&gt;

&lt;p&gt;If a fact is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;enforced by code,&lt;/li&gt;
&lt;li&gt;introspectable by reflection,&lt;/li&gt;
&lt;li&gt;and meaningful as structured metadata,&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then an attribute is a good home for it.&lt;/p&gt;

&lt;p&gt;If a fact requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rationale,&lt;/li&gt;
&lt;li&gt;examples,&lt;/li&gt;
&lt;li&gt;sequencing,&lt;/li&gt;
&lt;li&gt;tradeoff discussion,&lt;/li&gt;
&lt;li&gt;or warnings about misuse,&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then it should not live only in attributes.&lt;/p&gt;

&lt;p&gt;That line saves teams from turning code into a documentation dumping ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to keep generated docs honest
&lt;/h2&gt;

&lt;p&gt;The failure mode is not using attributes. The failure mode is shipping generated output with no editorial contract around it.&lt;/p&gt;

&lt;p&gt;The easiest fix is to define a small documentation architecture instead of hoping generators produce finished work.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Decide what attributes are allowed to own
&lt;/h3&gt;

&lt;p&gt;Be explicit. Do not let every team invent its own doctrine.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;attributes own route, schema, auth, and response metadata&lt;/li&gt;
&lt;li&gt;markdown docs own tutorials, workflows, migration notes, and decision rules&lt;/li&gt;
&lt;li&gt;changelogs own release deltas&lt;/li&gt;
&lt;li&gt;examples live in tested fixtures or example requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds obvious, but most teams skip it. Then they wonder why half the documentation lives in controllers and the other half in Confluence gravesites.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Generate references, then curate entry points
&lt;/h3&gt;

&lt;p&gt;A generated OpenAPI page is not a developer experience. It is a reference artifact.&lt;/p&gt;

&lt;p&gt;You still need curated entry points such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Start here” guides&lt;/li&gt;
&lt;li&gt;common workflows&lt;/li&gt;
&lt;li&gt;auth setup&lt;/li&gt;
&lt;li&gt;pagination and rate-limit behavior&lt;/li&gt;
&lt;li&gt;versioning rules&lt;/li&gt;
&lt;li&gt;examples that reflect real client use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generated reference should support those pages, not replace them.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Treat examples as first-class documentation
&lt;/h3&gt;

&lt;p&gt;This is where attribute-driven systems are usually weak. They capture schemas well and examples poorly.&lt;/p&gt;

&lt;p&gt;If your docs generator supports examples, use them aggressively. If it does not, keep tested examples close to the codebase and pull them into editorial docs. A technically correct schema without one realistic example is far less useful than teams admit.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Review attributes like API surface, not like decoration
&lt;/h3&gt;

&lt;p&gt;Attribute changes should be treated as contract changes when they affect external behavior. That means code review should ask questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the response contract change?&lt;/li&gt;
&lt;li&gt;Is the status code still correct?&lt;/li&gt;
&lt;li&gt;Are nullable fields still truthful?&lt;/li&gt;
&lt;li&gt;Does the summary reflect actual use, not just implementation detail?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If reviewers treat attributes like harmless syntax garnish, the docs will rot even though they are generated.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Add drift checks that matter
&lt;/h3&gt;

&lt;p&gt;The best attribute-based documentation setups fail loudly when the generated output and committed artifacts diverge.&lt;/p&gt;

&lt;p&gt;In practice, that means things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generating OpenAPI in CI&lt;/li&gt;
&lt;li&gt;diffing generated spec files&lt;/li&gt;
&lt;li&gt;rejecting undocumented breaking changes&lt;/li&gt;
&lt;li&gt;testing examples when possible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Automation cannot create good prose, but it can stop bad reference drift from slipping through quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape I would recommend
&lt;/h2&gt;

&lt;p&gt;For a Laravel or broader PHP team, I would use a three-layer model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: attributes for structured source truth
&lt;/h3&gt;

&lt;p&gt;Use PHP attributes for metadata that is local, machine-readable, and tied directly to code.&lt;/p&gt;

&lt;p&gt;That includes route contracts, schemas, auth hints, and other declarative surface details.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: generated reference for consistency
&lt;/h3&gt;

&lt;p&gt;Use tools like swagger-php to produce API reference artifacts from that source truth. Let generators handle the repetitive shape of endpoint docs so humans do not waste effort retyping stable contract data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: editorial docs for meaning
&lt;/h3&gt;

&lt;p&gt;Write short, opinionated docs that explain how the system is actually used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which endpoints matter most&lt;/li&gt;
&lt;li&gt;common integration flows&lt;/li&gt;
&lt;li&gt;business caveats&lt;/li&gt;
&lt;li&gt;performance traps&lt;/li&gt;
&lt;li&gt;migration notes&lt;/li&gt;
&lt;li&gt;examples that reflect reality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last layer is the one teams try to skip. It is also the layer that separates “technically documented” from “actually usable.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical recommendation
&lt;/h2&gt;

&lt;p&gt;PHP attributes are absolutely worth using for documentation-related metadata. They reduce one of the most annoying forms of drift by keeping structured facts close to the implementation. For Laravel teams especially, that is a real win.&lt;/p&gt;

&lt;p&gt;But they are not a substitute for documentation design.&lt;/p&gt;

&lt;p&gt;If you want docs that developers trust, use attributes to capture &lt;strong&gt;reference truth&lt;/strong&gt;, use generation to keep that truth consistent, and use editorial writing to explain how the system should be used in practice. That is the balance that works.&lt;/p&gt;

&lt;p&gt;The decision rule is simple: &lt;strong&gt;attributes should document what the code can assert confidently; humans should document what readers still need help understanding.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/php-attributes-as-documentation-useful-shortcut-or-drift-trap/" rel="noopener noreferrer"&gt;https://qcode.in/php-attributes-as-documentation-useful-shortcut-or-drift-trap/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>opendata</category>
      <category>webdev</category>
    </item>
    <item>
      <title>When Transaction Pooling Starts Making Sense in Laravel</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Sat, 11 Jul 2026 11:49:17 +0000</pubDate>
      <link>https://dev.to/saqueib/when-transaction-pooling-starts-making-sense-in-laravel-4kel</link>
      <guid>https://dev.to/saqueib/when-transaction-pooling-starts-making-sense-in-laravel-4kel</guid>
      <description>&lt;p&gt;If your Laravel app is running into Postgres connection limits, &lt;strong&gt;transaction pooling is usually the first scaling move worth testing&lt;/strong&gt;. Not replicas. Not sharding. Not a panicked rewrite of every query that shows up in a slow log. Busy Laravel systems often burn more capacity on connection churn than on actual SQL execution.&lt;/p&gt;

&lt;p&gt;That is why PgBouncer-style transaction pooling keeps showing up in serious Postgres setups. PHP requests are short-lived. Horizon workers fan out under load. Scheduled jobs arrive in bursts. Admin dashboards and exports create ugly spikes. Those workloads generate a lot of clients that want database access, but very few of them actually need a dedicated Postgres backend for their whole lifetime.&lt;/p&gt;

&lt;p&gt;The catch is simple: &lt;strong&gt;transaction pooling only works cleanly when your app behaves like a stateless SQL client between transactions&lt;/strong&gt;. A lot of Laravel code already fits that model. The parts that do not are usually the parts nobody has reviewed carefully in months: tenancy boot logic, raw SQL helpers, legacy console commands, import jobs, or package glue code.&lt;/p&gt;

&lt;p&gt;My recommendation is straightforward. Use transaction pooling when connection count is the real bottleneck, but treat the migration like a code audit, not a config tweak. Pooling is very good at exposing hidden session-level assumptions. That is exactly why it helps and exactly why careless rollouts hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottleneck is often connection churn before it is query speed
&lt;/h2&gt;

&lt;p&gt;Laravel apps usually hit this wall in a boring, predictable way. A web tier opens one class of connections. Horizon opens another. Scheduler-driven work wakes up on the minute. Then a report export, a bulk import, or a deploy lands at the wrong time and Postgres suddenly looks fragile.&lt;/p&gt;

&lt;p&gt;Teams often misread that moment. They assume the database is slow because slow pages and queue lag are what users notice. But Postgres pays real overhead for every backend process: memory, scheduler pressure, transaction bookkeeping, idle session cost, and general connection management. A database can look unhealthy at 800 live connections even when the actual query workload would be fine at 120 pooled server connections.&lt;/p&gt;

&lt;p&gt;That distinction matters because the fix is different. If the real ceiling is connection count, &lt;strong&gt;a pooler can buy back headroom faster than incremental query cleanup&lt;/strong&gt;. You should still fix bad queries. You should still add indexes where they belong. But that is not the same problem.&lt;/p&gt;

&lt;p&gt;Laravel now acknowledges this directly in its PostgreSQL docs with pooled connections and a separate &lt;code&gt;direct&lt;/code&gt; path for migrations and maintenance work: &lt;a href="https://laravel.com/docs/13.x/database#pooled-postgresql-connections" rel="noopener noreferrer"&gt;Laravel database docs&lt;/a&gt;. That is a useful signal. The framework is effectively saying what operations teams learned the hard way years ago: application traffic and schema traffic do not want the same connection behavior.&lt;/p&gt;

&lt;p&gt;A busy Laravel app is a good candidate for transaction pooling when these symptoms show up together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres connection usage spikes far harder than CPU or I/O.&lt;/li&gt;
&lt;li&gt;Queue bursts hurt more than average request volume.&lt;/li&gt;
&lt;li&gt;Deploy windows or cron-heavy periods create short but ugly saturation.&lt;/li&gt;
&lt;li&gt;Most requests and jobs are short units of work, not long-lived database conversations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that sounds familiar, transaction pooling is not premature optimization. It is often the cleanest first correction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What transaction pooling really changes
&lt;/h2&gt;

&lt;p&gt;The mechanics are simple enough, but the behavioral consequences matter.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;session pooling&lt;/strong&gt;, one client gets one server connection for the lifetime of that client session. With &lt;strong&gt;transaction pooling&lt;/strong&gt;, a client borrows a real Postgres backend only while a transaction is active, then gives it back to the pool. That model dramatically improves server connection reuse under bursty traffic.&lt;/p&gt;

&lt;p&gt;It also kills the assumption that the next statement from your app will land on the same backend session.&lt;/p&gt;

&lt;p&gt;That is the boundary teams have to understand. PgBouncer is explicit that transaction pooling breaks ordinary use of session-bound features like &lt;code&gt;SET/RESET&lt;/code&gt;, &lt;code&gt;LISTEN&lt;/code&gt;, &lt;code&gt;PREPARE / DEALLOCATE&lt;/code&gt;, preserved temp tables, and session advisory locks: &lt;a href="https://www.pgbouncer.org/features.html" rel="noopener noreferrer"&gt;PgBouncer feature matrix&lt;/a&gt;. PostgreSQL’s own docs explain why one of those failures happens: prepared statements created with &lt;code&gt;PREPARE&lt;/code&gt; live only for the duration of the current server session: &lt;a href="https://www.postgresql.org/docs/current/sql-prepare.html" rel="noopener noreferrer"&gt;PostgreSQL PREPARE docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the real question is not whether Laravel supports pooling. It does. The real question is whether your app depends on &lt;strong&gt;connection-local state surviving longer than one transaction&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What usually works fine
&lt;/h3&gt;

&lt;p&gt;Most normal Laravel application code survives transaction pooling without much drama:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Eloquent queries&lt;/li&gt;
&lt;li&gt;query-builder reads and writes&lt;/li&gt;
&lt;li&gt;standard &lt;code&gt;DB::transaction()&lt;/code&gt; blocks&lt;/li&gt;
&lt;li&gt;queue jobs that read, write, commit, and exit&lt;/li&gt;
&lt;li&gt;row-locking patterns that stay inside one real transaction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why pooling usually fits Laravel better than it fits more stateful application models. PHP request lifecycles are already short. The framework mostly encourages unit-of-work style database access.&lt;/p&gt;

&lt;h3&gt;
  
  
  What usually breaks first
&lt;/h3&gt;

&lt;p&gt;The failures are rarely exotic. They are just easy to ignore until a pooler forces the issue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SET search_path&lt;/code&gt; or similar session config done once and assumed forever&lt;/li&gt;
&lt;li&gt;raw &lt;code&gt;PREPARE&lt;/code&gt; usage&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; implemented through app connections&lt;/li&gt;
&lt;li&gt;temp tables reused outside a single transaction boundary&lt;/li&gt;
&lt;li&gt;session advisory locks&lt;/li&gt;
&lt;li&gt;long-running worker logic that quietly assumes backend session affinity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why one part of an app can look perfect behind PgBouncer while a forgotten console command blows up. The pooler is not being inconsistent. The codebase is revealing where session behavior leaked into application logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel now gives you a much cleaner migration shape
&lt;/h2&gt;

&lt;p&gt;A few years ago, teams often shoved PgBouncer in front of production, flipped environment variables, and found out what broke by reading support messages. Laravel’s newer pooled Postgres support gives you a better model than that.&lt;/p&gt;

&lt;p&gt;A practical config looks like this:&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="s1"&gt;'pgsql'&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;'driver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pgsql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_URL'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_HOST'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5432'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DATABASE'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'charset'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'utf8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'prefix'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'prefix_indexes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'search_path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'sslmode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'prefer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'pooled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_POOLED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'direct'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_HOST'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_PORT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5432'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_USERNAME'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_PASSWORD'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'sslmode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DB_DIRECT_SSLMODE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'prefer'&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 structure matters for two reasons.&lt;/p&gt;

&lt;p&gt;First, Laravel can automatically use the &lt;strong&gt;direct&lt;/strong&gt; connection for migrations, schema dumps, restores, &lt;code&gt;db:wipe&lt;/code&gt;, &lt;code&gt;db:show&lt;/code&gt;, and related commands when pooled mode is enabled. That is the right default. DDL and maintenance workflows are exactly where transaction pooling is least pleasant.&lt;/p&gt;

&lt;p&gt;Second, Laravel enables &lt;strong&gt;emulated prepares&lt;/strong&gt; for pooled connections. That is not random framework trivia. It is Laravel acknowledging the same compatibility line PgBouncer documents. If your code depends on native session-level prepare semantics, a pooled connection changes the ground rules.&lt;/p&gt;

&lt;p&gt;The operational rule should be brutally simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application traffic goes through the pool. Schema and maintenance work gets a direct path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you need that direct path from code, make it explicit:&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="no"&gt;Illuminate\Support\Facades\DB&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;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pgsql::direct'&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;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'create extension if not exists pg_trgm'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That explicitness matters. Hidden infrastructure exceptions are exactly what make rollbacks and late-night repairs harder than they should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Laravel-specific traps most teams miss
&lt;/h2&gt;

&lt;p&gt;The obvious trap is raw &lt;code&gt;PREPARE&lt;/code&gt;. The more common traps are subtler and usually more expensive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenancy or schema boot logic hidden behind &lt;code&gt;SET&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Some multi-tenant or multi-schema apps use &lt;code&gt;SET search_path&lt;/code&gt; in middleware, a tenant resolver, or a low-level helper. That can appear to work fine for years if one app connection effectively keeps one backend session. Under transaction pooling, that assumption disappears.&lt;/p&gt;

&lt;p&gt;If schema context matters, move that intent into durable connection configuration or explicit SQL design. &lt;strong&gt;Session-local setup is not a reliable contract once server connections are shared.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Workers that act like mini database sessions
&lt;/h3&gt;

&lt;p&gt;A lot of queue jobs are perfectly safe behind PgBouncer. The dangerous ones are the jobs that behave like miniature session-oriented scripts.&lt;/p&gt;

&lt;p&gt;That usually means jobs that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create temp tables&lt;/li&gt;
&lt;li&gt;depend on advisory locks casually&lt;/li&gt;
&lt;li&gt;hold transactions open while calling external APIs&lt;/li&gt;
&lt;li&gt;expect multiple statements to preserve backend-specific state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This does not mean workers and transaction pooling do not mix. They usually mix well. It means badly shaped workers become impossible to ignore once the pooler removes backend affinity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confusing Laravel &lt;code&gt;sticky&lt;/code&gt; with session affinity
&lt;/h3&gt;

&lt;p&gt;Laravel’s read/write &lt;code&gt;sticky&lt;/code&gt; option is useful, but teams routinely overread what it gives them. The docs are precise: if a write occurs during the current request cycle, later reads in that same request will use the write connection: &lt;a href="https://laravel.com/docs/13.x/database#read-and-write-connections" rel="noopener noreferrer"&gt;sticky option&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That does &lt;strong&gt;not&lt;/strong&gt; mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;future requests keep the same backend session&lt;/li&gt;
&lt;li&gt;queue workers inherit read-your-write guarantees from HTTP requests&lt;/li&gt;
&lt;li&gt;replica lag stops mattering&lt;/li&gt;
&lt;li&gt;transaction pooling becomes stateful&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This confusion matters because teams sometimes blame pooling for bugs that are really consistency or routing issues. Those are adjacent concerns, not the same concern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Old commands nobody owns anymore
&lt;/h3&gt;

&lt;p&gt;The highest-risk code is often not in controllers or service classes. It is in legacy Artisan commands, admin repair scripts, report generators, import tools, or package integration code. Those places are exactly where temp tables, &lt;code&gt;LISTEN&lt;/code&gt;, session tweaks, or unusual transaction assumptions like to hide.&lt;/p&gt;

&lt;p&gt;If your migration validation is mostly browser clicks, you are validating the safest slice of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I would audit a real Laravel codebase before cutover
&lt;/h2&gt;

&lt;p&gt;The right posture is simple: assume hidden session coupling exists until you prove otherwise.&lt;/p&gt;

&lt;p&gt;Start with a blunt search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rg &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"SET |LISTEN|NOTIFY|PREPARE|DEALLOCATE|pg_advisory_lock|pg_try_advisory_lock|create temp|search_path"&lt;/span&gt; app/ bootstrap/ config/ database/ routes/ tests/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That search is intentionally ugly. It is supposed to catch suspicious patterns quickly, not look elegant in a slide deck.&lt;/p&gt;

&lt;p&gt;Then review the parts of the system that are least like ordinary request handling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;imports and exports&lt;/li&gt;
&lt;li&gt;raw SQL reporting jobs&lt;/li&gt;
&lt;li&gt;scheduled maintenance commands&lt;/li&gt;
&lt;li&gt;workers that mix transactions with network calls&lt;/li&gt;
&lt;li&gt;retry-heavy jobs with partial side effects&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would classify what I find into three buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Safe through the pool&lt;/strong&gt;: ordinary app traffic, standard transactions, simple jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Needs review&lt;/strong&gt;: raw SQL, odd workers, schema-sensitive flows, temp tables, advisory-lock usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct only&lt;/strong&gt;: migrations, extension setup, schema repair, operational maintenance tooling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That classification turns the migration from guesswork into policy. It also makes the inevitable exception paths visible early instead of during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test the move without lying to yourself
&lt;/h2&gt;

&lt;p&gt;Feature tests alone are not enough. The migration changes how database connections are allocated under concurrency, so the validation has to exercise that shape.&lt;/p&gt;

&lt;p&gt;The goals are straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;normal pooled app traffic still behaves correctly&lt;/li&gt;
&lt;li&gt;direct-path operational tasks still work&lt;/li&gt;
&lt;li&gt;connection pressure drops without moving the bottleneck somewhere worse&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A small smoke suite aimed directly at the migration seam is more useful than a large generic suite here:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'keeps ordinary transactional work safe behind the pool'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&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;insert&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'number'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ORD-1001'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&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;'number'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ORD-1001'&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;lockForUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&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;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'uses the direct connection for schema-only work'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&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="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pgsql::direct'&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;statement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'create extension if not exists pgcrypto'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then stage the pool under traffic that resembles production, not a polite demo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;concurrent HTTP requests&lt;/li&gt;
&lt;li&gt;Horizon bursts&lt;/li&gt;
&lt;li&gt;scheduler windows&lt;/li&gt;
&lt;li&gt;dashboards and exports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And watch the signals that actually matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;active Postgres server connections&lt;/li&gt;
&lt;li&gt;connection churn during spikes and deploys&lt;/li&gt;
&lt;li&gt;PgBouncer wait time and pool saturation&lt;/li&gt;
&lt;li&gt;queue latency and retry behavior&lt;/li&gt;
&lt;li&gt;worker-specific or raw-SQL error rates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If latency stays acceptable while Postgres connection pressure drops materially, that is a real win. If PgBouncer becomes a queueing bottleneck or long transactions start starving throughput, you have at least learned something honest before production traffic teaches it harder.&lt;/p&gt;

&lt;p&gt;Do one fallback drill too. Before cutover, prove that direct-path migrations and maintenance commands still work when bypassing the pool. That step is boring, but it is the kind of boring step that prevents a rollback from becoming a second outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical rule
&lt;/h2&gt;

&lt;p&gt;For busy Laravel apps, transaction pooling is usually the right move when &lt;strong&gt;connection count is the actual bottleneck and the application mostly behaves like short, stateless units of SQL work&lt;/strong&gt;. Delay it when the codebase still depends on connection-local behavior it has never isolated properly.&lt;/p&gt;

&lt;p&gt;The upside is real. Postgres spends more time executing useful queries and less time babysitting idle or bursty client sessions. Queue spikes become less wasteful. Deploy windows get calmer. But teams only get those benefits when they stop pretending the database session is part of their application state.&lt;/p&gt;

&lt;p&gt;That is the lesson worth keeping: &lt;strong&gt;pool aggressively, but treat database session state as a liability unless you can prove you truly need it.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/postgres-transaction-pooling-lessons-for-busy-laravel-apps/" rel="noopener noreferrer"&gt;https://qcode.in/postgres-transaction-pooling-lessons-for-busy-laravel-apps/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>postgres</category>
      <category>pgbouncer</category>
      <category>scaling</category>
    </item>
    <item>
      <title>Feature flag debt gets worse when frontend and backend ship on different clocks</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Sun, 05 Jul 2026 04:19:43 +0000</pubDate>
      <link>https://dev.to/saqueib/feature-flag-debt-gets-worse-when-frontend-and-backend-ship-on-different-clocks-584k</link>
      <guid>https://dev.to/saqueib/feature-flag-debt-gets-worse-when-frontend-and-backend-ship-on-different-clocks-584k</guid>
      <description>&lt;p&gt;Feature flags are supposed to make releases safer. In practice, they often create a second deployment system that nobody treats with the same rigor as the first one.&lt;/p&gt;

&lt;p&gt;That gets dangerous when your frontend and backend do not ship on the same clock. The SPA is cached globally and updates in minutes. The API rolls out gradually. A worker fleet is still on the previous image. Analytics schemas lag behind both. The flag flips anyway. Now the client assumes a capability that the server does not support yet, or the backend starts emitting shapes the frontend does not understand.&lt;/p&gt;

&lt;p&gt;This is where feature flag debt stops being a cleanup issue and turns into a reliability issue. &lt;strong&gt;The real problem is not flags themselves. It is uncoordinated assumptions across independently moving systems.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is straightforward, but it requires discipline: design flags around capability boundaries, not UI moments; make backend compatibility the default; and treat flag rollout like a cross-system contract, not a boolean in a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broken state usually comes from mismatched assumptions
&lt;/h2&gt;

&lt;p&gt;Most teams first feel this problem in a simple way. A button appears in the UI, but clicking it returns &lt;code&gt;404&lt;/code&gt; or &lt;code&gt;403&lt;/code&gt;. Or a page assumes a new response field exists, but one API pod is still serving the old schema. Or a background job starts processing a “new flow” event while the consumer that understands that event has not rolled out yet.&lt;/p&gt;

&lt;p&gt;None of these failures are exotic. They happen because a flag is often modeled at the wrong layer.&lt;/p&gt;

&lt;p&gt;A common anti-pattern looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the frontend checks &lt;code&gt;new_checkout_enabled&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the backend separately checks &lt;code&gt;new_checkout_enabled&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a worker separately checks &lt;code&gt;new_checkout_enabled&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;analytics separately checks &lt;code&gt;new_checkout_enabled&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That looks coordinated, but it is not. It assumes every runtime sees the same flag value, on the same schedule, with the same deployment state, and interprets the flag identically. That assumption is weak in any distributed system.&lt;/p&gt;

&lt;p&gt;What actually happens is this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The flag turns on for 10% of users.&lt;/li&gt;
&lt;li&gt;Some frontend clients fetch the new value immediately.&lt;/li&gt;
&lt;li&gt;Some backend instances are still on the old release.&lt;/li&gt;
&lt;li&gt;Some queued jobs were created before the flag changed.&lt;/li&gt;
&lt;li&gt;Some event consumers lag behind by minutes or hours.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now “the flag is on” does not mean one thing. It means five slightly different things across the stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  A flag should not be your compatibility layer
&lt;/h3&gt;

&lt;p&gt;Teams get into trouble when they expect the flag service to solve coordination by itself. It cannot. A flag can decide whether a code path is eligible. It cannot guarantee that every dependent system is already compatible.&lt;/p&gt;

&lt;p&gt;That means your system needs a stronger invariant than “this flag is true.” The invariant should be closer to this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If any part of the stack sees the new path, every downstream dependency can either handle it or degrade safely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the bar. If you cannot meet it, the flag is being used too early or at the wrong boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with backend compatibility, not frontend exposure
&lt;/h2&gt;

&lt;p&gt;If backend and frontend ship independently, the safest rollout order is boring: &lt;strong&gt;make the backend capable first, then expose the frontend later&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This sounds obvious, but teams regularly invert it because the UI is the visible part of the feature. That creates the exact broken state feature flags were supposed to avoid.&lt;/p&gt;

&lt;p&gt;A better model is to split a feature into separate concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;backend capability exists&lt;/li&gt;
&lt;li&gt;API contract supports old and new clients&lt;/li&gt;
&lt;li&gt;jobs and consumers can process both versions&lt;/li&gt;
&lt;li&gt;analytics can ingest both event shapes&lt;/li&gt;
&lt;li&gt;frontend exposure is enabled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not the same milestone. Treating them as one switch creates debt fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Additive change beats conditional change
&lt;/h3&gt;

&lt;p&gt;The safest flaggable change is usually additive. Add a new field, a new endpoint, a new optional behavior, or a new event version while keeping the old path valid.&lt;/p&gt;

&lt;p&gt;For example, this is safer than replacing a response shape outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"available"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"redirect_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/checkout/v2/session/abc123"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An old client can ignore &lt;code&gt;checkout&lt;/code&gt;. A new client can use it if present. That is much safer than making the frontend assume a brand-new route exists because a UI flag was turned on.&lt;/p&gt;

&lt;p&gt;The same principle applies to commands and jobs. If a worker starts seeing a new payload shape, it should either understand both versions or reject the unsupported one explicitly and visibly. Silent partial handling is where long-lived flag debt hides.&lt;/p&gt;

&lt;h3&gt;
  
  
  Capability detection is stronger than UI gating
&lt;/h3&gt;

&lt;p&gt;A useful pattern is to let the backend expose capability, then let the frontend decide whether to render the experience.&lt;/p&gt;

&lt;p&gt;Instead of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newBillingFlow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showNewBillingPage&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;prefer something closer to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canUseNewBilling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newBillingFlow&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;apiCapabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billingFlow&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v2&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;canUseNewBilling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showNewBillingPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showLegacyBillingPage&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;Now the flag is no longer the sole source of truth. The rendered state depends on an actual server capability signal.&lt;/p&gt;

&lt;p&gt;That gives you a safer rollout path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy backend support for &lt;code&gt;billingFlow=v2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Let the API advertise the capability.&lt;/li&gt;
&lt;li&gt;Roll out workers and analytics support.&lt;/li&gt;
&lt;li&gt;Turn on frontend exposure only where capability is confirmed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is more tedious than flipping one boolean. It is also how you avoid shipping fake availability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design flag rollout as a contract across systems
&lt;/h2&gt;

&lt;p&gt;Once a feature touches more than one runtime, your rollout plan should be explicit. Not “we’ll turn it on gradually.” Explicit.&lt;/p&gt;

&lt;p&gt;A useful mental model is that every multi-system feature has at least four contracts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;read contract&lt;/strong&gt;: what responses can clients safely consume?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;write contract&lt;/strong&gt;: what payloads can clients or jobs safely send?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;processing contract&lt;/strong&gt;: what events or commands can workers safely handle?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;measurement contract&lt;/strong&gt;: what analytics events and schemas stay valid during the rollout?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only think about the first one, you will break the other three.&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical rollout sequence
&lt;/h3&gt;

&lt;p&gt;For most full-stack product work, the sequence below is safer than ad hoc toggling:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy passive backend support first.&lt;/li&gt;
&lt;li&gt;Make response changes additive and backwards compatible.&lt;/li&gt;
&lt;li&gt;Update workers, consumers, and analytics to understand both old and new shapes.&lt;/li&gt;
&lt;li&gt;Emit observability signals for the new path while exposure is still off.&lt;/li&gt;
&lt;li&gt;Enable the flag for internal users or a tiny cohort.&lt;/li&gt;
&lt;li&gt;Verify end-to-end behavior across API, queue, and analytics.&lt;/li&gt;
&lt;li&gt;Expand exposure gradually.&lt;/li&gt;
&lt;li&gt;Remove the old path and the flag only after the new path is boring.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important bit is step 4. Teams often wait to add monitoring until after exposure starts. That is backwards. You want to know whether the system can survive the new path before real users hit it at scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate release flags from ops flags
&lt;/h3&gt;

&lt;p&gt;Another source of debt is overloading one flag with multiple meanings.&lt;/p&gt;

&lt;p&gt;A release flag answers: “Should users see this feature yet?”&lt;/p&gt;

&lt;p&gt;An operational flag answers: “Should this subsystem execute a risky behavior right now?”&lt;/p&gt;

&lt;p&gt;Those are not interchangeable. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;show_new_import_ui&lt;/code&gt; is a release flag&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;use_new_import_pipeline&lt;/code&gt; is an operational flag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you collapse them into one boolean, you lose control. Sometimes you want the UI visible but the old processing pipeline still active in fallback mode. Sometimes you want the backend pipeline live for internal traffic before any public UI exists.&lt;/p&gt;

&lt;p&gt;Separate flags create more names, but fewer accidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make asynchronous systems first-class in the design
&lt;/h2&gt;

&lt;p&gt;Feature flag discussions are often too request-response centric. Real production systems are not just browser plus API. They include queues, cron jobs, webhooks, caches, read replicas, and analytics sinks. Those are usually where rollout bugs become hard to trace.&lt;/p&gt;

&lt;p&gt;If a user action under a new flag emits an event that is processed five minutes later by a stale consumer, your deploy is still broken even if the initial API response looked correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version messages, not just endpoints
&lt;/h3&gt;

&lt;p&gt;Teams are often careful with API versioning and careless with internal event versioning. That is a mistake.&lt;/p&gt;

&lt;p&gt;If a flagged feature changes event semantics, add an explicit version or event type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"checkout.completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"flow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"express"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sess_abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives consumers a clear branch instead of forcing them to infer which world they are in.&lt;/p&gt;

&lt;p&gt;The goal is not elegant payloads. The goal is survivable change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Queues preserve old assumptions longer than you think
&lt;/h3&gt;

&lt;p&gt;Queued work is where “the rollout is complete” becomes fiction. Jobs created before a flag flip may run after the flip. Retried jobs may execute on newer workers with older payload assumptions. Dead-letter replay may resurrect paths you thought were gone.&lt;/p&gt;

&lt;p&gt;That means job handlers should be written with rollout windows in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accept old and new payload versions during migration&lt;/li&gt;
&lt;li&gt;avoid resolving behavior from current flag state if the job was created under older assumptions&lt;/li&gt;
&lt;li&gt;store explicit mode or version on the job payload when behavior matters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is safer than doing something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'new_settlement_flow'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;runNewSettlement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;runLegacySettlement&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 code is fragile because execution-time flag state may not match enqueue-time intent.&lt;/p&gt;

&lt;p&gt;A safer approach is to persist the decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessSettlement&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="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="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$paymentId&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;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$flowVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&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;flowVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s1"&gt;'v2'&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;runNewSettlement&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="k"&gt;default&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;runLegacySettlement&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 is the kind of detail that prevents async systems from drifting away from the request path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build observability around rollout mismatches
&lt;/h2&gt;

&lt;p&gt;Most flag rollouts are monitored too shallowly. Teams watch error rate and maybe conversion. That catches catastrophic failure, but not subtle mismatch.&lt;/p&gt;

&lt;p&gt;You need telemetry that answers a more specific question: &lt;strong&gt;are different layers of the stack behaving as if they are in different release states?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That means logging and dashboards around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;frontend flag state versus backend capability response&lt;/li&gt;
&lt;li&gt;request volume by feature version&lt;/li&gt;
&lt;li&gt;job volume by payload version&lt;/li&gt;
&lt;li&gt;event consumer success rate by version&lt;/li&gt;
&lt;li&gt;analytics ingestion errors by schema version&lt;/li&gt;
&lt;li&gt;fallback path usage after the flag is supposedly “on”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the frontend renders v2 for 20% of users but only 8% of API writes are hitting the v2 path, you have a coordination problem. If v2 jobs enqueue fine but v2 consumer handling lags, you have a coordination problem. If analytics starts dropping new events, the release is not healthy even if users do not notice immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Log decisions, not just failures
&lt;/h3&gt;

&lt;p&gt;One of the best habits here is to log rollout decisions explicitly.&lt;/p&gt;

&lt;p&gt;For example, include structured fields like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feature=checkout_v2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;flag_state=true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;api_capability=v2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;selected_flow=v2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;job_payload_version=2&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That lets you reconstruct not only that something failed, but &lt;strong&gt;why the system believed it should take that path&lt;/strong&gt;. Without that, debugging coordinated rollouts becomes guesswork.&lt;/p&gt;

&lt;p&gt;This matters even more when multiple flags overlap. Two independent booleans can create four states. Three booleans create eight. If you are not logging the decision inputs, you are debugging a state machine blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature flag debt is mostly lifecycle debt
&lt;/h2&gt;

&lt;p&gt;A lot of teams talk about flag debt as “too many old flags.” That is true, but incomplete. The more dangerous debt is flags with unclear ownership, unclear rollout semantics, and no removal plan.&lt;/p&gt;

&lt;p&gt;A feature flag should have metadata that answers basic operational questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who owns it?&lt;/li&gt;
&lt;li&gt;what systems depend on it?&lt;/li&gt;
&lt;li&gt;is it release, experiment, permission, or ops control?&lt;/li&gt;
&lt;li&gt;what is the expected cleanup date?&lt;/li&gt;
&lt;li&gt;what metrics prove the old path is safe to delete?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you cannot answer those questions, the flag is already a liability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Short-lived release flags should stay short-lived
&lt;/h3&gt;

&lt;p&gt;Most release flags should not live for months. If they do, they stop being rollout controls and become permanent branching logic.&lt;/p&gt;

&lt;p&gt;That creates three costs at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;engineers have to reason about both worlds forever&lt;/li&gt;
&lt;li&gt;tests multiply across paths nobody wants to maintain&lt;/li&gt;
&lt;li&gt;async and analytics systems carry compatibility baggage long after the rollout ended&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical rule is to decide the exit criteria when you create the flag, not when you are tired of looking at it.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;remove after 100% rollout holds for two weeks&lt;/li&gt;
&lt;li&gt;remove after v2 job traffic reaches 100% and no v1 jobs remain in flight&lt;/li&gt;
&lt;li&gt;remove after analytics confirms the old schema path is unused&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is better than leaving “cleanup later” in a ticket graveyard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do differently on your next flagged release
&lt;/h2&gt;

&lt;p&gt;If your stack ships frontend, backend, and workers independently, assume clock drift by default. Do not design flags as if every component updates in lockstep.&lt;/p&gt;

&lt;p&gt;Start by making backend changes additive. Expose capabilities explicitly. Persist version decisions into jobs and events instead of re-deriving them from current flag state. Split release flags from operational flags. Add rollout telemetry before exposure starts. Then remove the old path aggressively once the new one is stable.&lt;/p&gt;

&lt;p&gt;The core decision rule is simple: &lt;strong&gt;a feature flag should never be the only thing standing between a user and an incompatible backend state&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If turning a flag on can expose assumptions that the rest of the stack has not caught up with, the rollout design is weak. Fix the contract first. Then flip the flag.&lt;/p&gt;

&lt;p&gt;That is how you keep feature flags useful instead of turning them into a distributed systems tax.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/feature-flag-debt-gets-worse-when-backend-and-frontend-ship-on-different-clocks/" rel="noopener noreferrer"&gt;https://qcode.in/feature-flag-debt-gets-worse-when-backend-and-frontend-ship-on-different-clocks/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>featureflags</category>
      <category>deployment</category>
      <category>fullstack</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Interactive Data Visualizations Need Backend Thinking Too</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Thu, 25 Jun 2026 05:48:00 +0000</pubDate>
      <link>https://dev.to/saqueib/interactive-data-visualizations-need-backend-thinking-too-5bk0</link>
      <guid>https://dev.to/saqueib/interactive-data-visualizations-need-backend-thinking-too-5bk0</guid>
      <description>&lt;p&gt;Interactive data visualizations get praised for the wrong layer. People notice the timeline scrubber, the map replay, the animated dots, the polished easing. They do not notice the ingestion job that cleaned broken timestamps, the cache strategy that kept the API alive, or the artifact builder that cut a 40 MB raw dataset into a 900 KB view payload.&lt;/p&gt;

&lt;p&gt;That invisible work is the product.&lt;/p&gt;

&lt;p&gt;If you are building data-heavy visual experiences, the frontend is not the system. It is the renderer. The real engineering challenge is deciding &lt;strong&gt;what data shape reaches the browser, when it arrives, how much of it arrives, and how repeatable that pipeline stays when traffic and source quality both get worse&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the practical takeaway up front: &lt;strong&gt;interactive visualizations need backend-first design if you want them to survive production traffic&lt;/strong&gt;. Otherwise you are shipping a demo with a nice animation layer on top of an unstable data path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser should render, not repair your data
&lt;/h2&gt;

&lt;p&gt;A lot of visualization stacks fail because the API contract is too close to the storage model. The backend exposes raw or lightly filtered records, then the client is forced to deduplicate, aggregate, interpolate, bucket, and infer meaning on every load.&lt;/p&gt;

&lt;p&gt;That looks flexible during development because the frontend team can move fast. It becomes expensive later because every user session now pays the cost of data cleanup, and every edge case turns into UI complexity.&lt;/p&gt;

&lt;p&gt;For animated station maps, fleet traces, or timeline replays, source data is usually hostile to direct rendering. Event streams arrive out of order. Repeated observations create fake density. Coordinates drift. Entity IDs are inconsistent across sources. Missing records create gaps the UI is expected to smooth over.&lt;/p&gt;

&lt;p&gt;If the browser receives this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"train_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"A12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"station"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Central"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;19.0728&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lng"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;72.8826&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"seen_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-25T10:00:02Z"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"train_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"A12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"station"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Central"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;19.0728&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lng"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;72.8826&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"seen_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-25T10:00:09Z"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"train_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"A12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"station"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"West End"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;19.0810&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lng"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;72.8951&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"seen_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-25T10:03:41Z"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then the frontend has to answer questions it should never own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the second record a duplicate or a meaningful stationary update?&lt;/li&gt;
&lt;li&gt;Should the gap between timestamps be interpolated?&lt;/li&gt;
&lt;li&gt;Is the implied path direct, snapped to rails, or unknown?&lt;/li&gt;
&lt;li&gt;What should happen if one station record is missing?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That logic belongs upstream. The client should consume something closer to a playback model than a source log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"A12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"revision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-25T10:05:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"segments"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Central"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"West End"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1782372002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1782372221&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:[[&lt;/span&gt;&lt;span class="mf"&gt;19.0728&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;72.8826&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="mf"&gt;19.0763&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;72.8880&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="mf"&gt;19.0810&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;72.8951&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single shift changes the whole system. CPU work moves out of the browser. Behavior becomes testable. Derived assumptions become explicit. Cacheability improves because the response shape now matches the interaction model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage models and view models should diverge on purpose
&lt;/h3&gt;

&lt;p&gt;This is the part many teams resist. They want one canonical API to serve every consumer. That instinct is understandable, but it is usually wrong for rich interactive views.&lt;/p&gt;

&lt;p&gt;A visualization surface is a specialized consumer. It needs fast sequential access, compact payloads, stable ordering, and derived state that would be wasteful or awkward in a general-purpose API. Trying to force the visualization to consume the same shape as admin dashboards, exports, and internal analytics usually creates the worst version of all of them.&lt;/p&gt;

&lt;p&gt;If the page is interactive, time-based, and traffic-facing, give it a dedicated backend contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preprocessing is where reliability actually comes from
&lt;/h2&gt;

&lt;p&gt;Most production-grade visualization systems are really two systems: a &lt;strong&gt;pipeline&lt;/strong&gt; that prepares data and a &lt;strong&gt;delivery layer&lt;/strong&gt; that serves prepared artifacts. Teams that skip the first one end up overloading the second.&lt;/p&gt;

&lt;p&gt;Preprocessing is not just a performance optimization. It is where you turn messy observational data into a product-grade representation that can be replayed consistently.&lt;/p&gt;

&lt;h3&gt;
  
  
  What preprocessing should handle
&lt;/h3&gt;

&lt;p&gt;For the class of experiences described here, preprocessing usually needs to do at least five things well.&lt;/p&gt;

&lt;p&gt;First, it validates source records. If timestamps are malformed, coordinates are outside expected bounds, or IDs fail normalization rules, the pipeline should reject or quarantine them before they contaminate downstream artifacts.&lt;/p&gt;

&lt;p&gt;Second, it normalizes structure. Time zones, units, entity identifiers, geometry formats, and schema variants need to collapse into one internal model.&lt;/p&gt;

&lt;p&gt;Third, it derives playback segments. Raw event rows are often too granular or too noisy. The useful artifact is a sequence of intervals, paths, summary points, or time buckets.&lt;/p&gt;

&lt;p&gt;Fourth, it materializes multiple resolutions. An overview map and an entity drill-down should not read the same fidelity level.&lt;/p&gt;

&lt;p&gt;Fifth, it stamps revisions. If a pipeline rebuild changes anything important, the delivery layer needs a durable version signal so stale and fresh fragments do not mix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducibility matters more than convenience
&lt;/h3&gt;

&lt;p&gt;A fragile pattern is doing the transformation work inside request handlers because it feels simpler. It is simpler for a week. Then traffic increases, one source adds a new field, or the user asks for a longer time range, and the request path turns into an accidental batch job.&lt;/p&gt;

&lt;p&gt;A better design is explicit and boring: ingest, normalize, derive, materialize, publish.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BuildVisualizationArtifacts&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;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$to&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;$rawEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SourceEventRepository&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EventNormalizer&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rawEvents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SegmentBuilder&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$normalized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$overview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OverviewAggregator&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$segments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$detailTiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DetailTileBuilder&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$segments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$revision&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;utc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'YmdHis'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ArtifactStore&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="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;"overview:&lt;/span&gt;&lt;span class="nv"&gt;$revision&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$overview&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ArtifactStore&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="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;"detail:&lt;/span&gt;&lt;span class="nv"&gt;$revision&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$detailTiles&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DatasetRevisionStore&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setCurrent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$revision&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frameworks and class names do not matter much. The architecture does. If you can rebuild the same artifact deterministically, inspect intermediate stages, and compare outputs across revisions, you have a real pipeline. If not, you are still depending on runtime improvisation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Uncertainty should be encoded, not hidden
&lt;/h3&gt;

&lt;p&gt;There is another reason preprocessing belongs on the backend: uncertainty handling. Many interactive datasets contain inferred positions, sparse ranges, or partial observations. If you smooth all of that into a visually confident animation, you create a lie with good design.&lt;/p&gt;

&lt;p&gt;Better systems keep uncertainty visible in the data model. A segment can carry &lt;code&gt;confidence&lt;/code&gt;, &lt;code&gt;interpolated&lt;/code&gt;, &lt;code&gt;sample_count&lt;/code&gt;, or &lt;code&gt;source_gap_ms&lt;/code&gt;. The UI can then use different styles or tooltips to communicate that some motion was derived rather than directly observed.&lt;/p&gt;

&lt;p&gt;The backend should not manufacture precision the source system never had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payload budgets should drive the API design
&lt;/h2&gt;

&lt;p&gt;If there is one mistake that keeps repeating in visualization projects, it is pretending that payload size is a frontend optimization problem. It is not. It is an API contract problem.&lt;/p&gt;

&lt;p&gt;By the time the frontend team is arguing about virtualized lists, memoization, or canvas layers, the more expensive mistake may already be locked in: the server is sending too much data for the interaction to feel immediate.&lt;/p&gt;

&lt;p&gt;A first-load payload is not just a technical number. It determines whether the product can support fast pan, zoom, scrub, hover, mobile use, and concurrent traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  The first response should be intentionally incomplete
&lt;/h3&gt;

&lt;p&gt;A strong visualization API does not try to send the universe on first load. It sends the smallest truthful view that gets the user oriented.&lt;/p&gt;

&lt;p&gt;That usually means bounding the request by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;time window&lt;/li&gt;
&lt;li&gt;viewport or region&lt;/li&gt;
&lt;li&gt;detail tier&lt;/li&gt;
&lt;li&gt;active filters&lt;/li&gt;
&lt;li&gt;current dataset revision&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/visualization/network-history
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;prefer this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/visualization/network-history?from=2026-06-01T00:00:00Z&amp;amp;to=2026-06-07T00:00:00Z&amp;amp;bbox=72.8,18.8,73.1,19.2&amp;amp;detail=overview
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That change is not cosmetic. It forces the backend to acknowledge that different user moments need different data density.&lt;/p&gt;

&lt;h3&gt;
  
  
  Detail tiers should be real, not rhetorical
&lt;/h3&gt;

&lt;p&gt;Many systems expose &lt;code&gt;summary=true&lt;/code&gt; or &lt;code&gt;detail=low&lt;/code&gt; parameters that barely change anything. That is fake progressive design. A real tiered strategy should produce meaningfully different artifacts.&lt;/p&gt;

&lt;p&gt;A practical split looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;overview&lt;/code&gt;: counts, coarse paths, simplified geometry, sparse labels&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;interactive&lt;/code&gt;: enough detail for timeline scrubbing and local filters&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inspect&lt;/code&gt;: selected entity details, per-segment metadata, richer annotations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tier should have a target payload range and a target latency envelope. If &lt;code&gt;overview&lt;/code&gt; and &lt;code&gt;interactive&lt;/code&gt; are nearly the same size, the backend is not doing enough shaping.&lt;/p&gt;

&lt;h3&gt;
  
  
  Downsampling is not compromise, it is product design
&lt;/h3&gt;

&lt;p&gt;Some engineers still treat downsampling like a reluctant concession. For interactive views, it is usually the correct move.&lt;/p&gt;

&lt;p&gt;When the user is zoomed out over a city or scrubbing a week of history, raw fidelity is mostly waste. It increases payload, clutters the display, and gives the illusion that more detail on screen means more truth. Often the reverse is true.&lt;/p&gt;

&lt;p&gt;A reduced representation can be more honest because it matches the question the user is actually asking at that zoom level. This is the same logic behind vector tiles, level-of-detail rendering, and multiresolution time-series systems. The interaction decides the density, not the storage layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching and progressive loading are where the UX is won
&lt;/h2&gt;

&lt;p&gt;Once you have preprocessed artifacts and bounded payloads, delivery becomes the next leverage point. This is where many visualization products either become pleasant or stay permanently sticky.&lt;/p&gt;

&lt;p&gt;Caching is not just about reducing database pressure. It is about making repeated navigation patterns feel instantaneous. Progressive loading is not just about skeleton screens. It is about sequencing information so the page becomes useful before the full detail graph exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache keys need semantic discipline
&lt;/h3&gt;

&lt;p&gt;Bad cache keys create bugs that look random from the frontend. One user sees stale geometry, another sees fresh counts, and a third causes needless rebuilds because the same filters serialized differently.&lt;/p&gt;

&lt;p&gt;The fix is to canonicalize request shape before hashing it. Include every dimension that changes the output, and do not forget the dataset revision.&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;$params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'from'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'from'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'to'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'to'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'bbox'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="s1"&gt;'floatval'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;explode&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bbox'&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
    &lt;span class="s1"&gt;'detail'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'detail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'overview'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'filters'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'filters'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
    &lt;span class="s1"&gt;'revision'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DatasetRevisionStore&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;current&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nb"&gt;ksort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'viz:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;sha1&lt;/span&gt;&lt;span class="p"&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;$params&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="nv"&gt;$cacheKey&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;15&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;$params&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;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;VisualizationPayloadBuilder&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&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 critical part is not the hash. It is the discipline around what enters the hash. If the current dataset revision is missing, you will eventually serve old slices after a pipeline rebuild and spend hours blaming the frontend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Progressive loading should mirror user intent
&lt;/h3&gt;

&lt;p&gt;A good mental model is to load the visualization in rings.&lt;/p&gt;

&lt;p&gt;The first ring is orientation: overview geometry, aggregate counts, bounds, and enough context to make the page feel alive.&lt;/p&gt;

&lt;p&gt;The second ring is interaction: the fidelity needed for timeline scrubbing, local filtering, and smooth pan or zoom transitions.&lt;/p&gt;

&lt;p&gt;The third ring is inspection: entity-level detail, richer tooltips, segment metadata, and anomaly explanations.&lt;/p&gt;

&lt;p&gt;That leads to a concrete sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Serve a compact overview artifact for first paint.&lt;/li&gt;
&lt;li&gt;Prefetch adjacent windows or nearby tiles if the user is likely to move there next.&lt;/li&gt;
&lt;li&gt;Fetch higher fidelity only after zoom, selection, or scrub focus narrows the question.&lt;/li&gt;
&lt;li&gt;Evict high-detail slices aggressively when they stop being relevant.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is what real progressive loading looks like. It is not “load everything eventually.” It is “load only what the current user moment justifies.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability is what keeps the visualization honest
&lt;/h2&gt;

&lt;p&gt;Visualization systems have a dangerous failure mode: they can be wrong and still look successful. The request returns &lt;code&gt;200&lt;/code&gt;. The animation plays. The UI feels smooth. Meanwhile the counts are stale, one category silently vanished after a schema change, or a path smoothing step introduced physically impossible movement.&lt;/p&gt;

&lt;p&gt;That is why plain API uptime is not enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measure the data product, not just the endpoint
&lt;/h3&gt;

&lt;p&gt;You should still track the standard operational metrics: latency, error rate, queue depth, database load. But for interactive visualizations, the important signals go further.&lt;/p&gt;

&lt;p&gt;You want visibility into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;payload size by endpoint and detail tier&lt;/li&gt;
&lt;li&gt;cache hit rate by request class&lt;/li&gt;
&lt;li&gt;artifact build duration and failure rate&lt;/li&gt;
&lt;li&gt;freshness lag between source ingestion and published revision&lt;/li&gt;
&lt;li&gt;percentage of interpolated or low-confidence segments&lt;/li&gt;
&lt;li&gt;frontend render time for overview versus detail payloads&lt;/li&gt;
&lt;li&gt;memory pressure in long-running sessions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These metrics expose product failures, not just infrastructure failures. A route can be fast and still useless if it returns 6 MB for a filter toggle. A pipeline can be correct and still dangerous if the published revision lags behind the live source by two hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validate derived truth before the UI sees it
&lt;/h3&gt;

&lt;p&gt;You also need backend-side validation that checks whether the exported artifact still resembles reality. That means rules like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;detect impossible jumps by distance and time&lt;/li&gt;
&lt;li&gt;compare aggregate totals before and after normalization&lt;/li&gt;
&lt;li&gt;flag sharp drops in entity counts after schema updates&lt;/li&gt;
&lt;li&gt;verify that known sample entities appear in each resolution tier&lt;/li&gt;
&lt;li&gt;alert when overview and detailed slices diverge beyond tolerance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the part teams skip because it feels less visible than the animation. It is also the part that saves you from shipping a beautiful lie.&lt;/p&gt;

&lt;p&gt;A derived visualization is a data product. Data products need validation, not just rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change in a real codebase
&lt;/h2&gt;

&lt;p&gt;If you already have an interactive visualization that feels fragile, do not start with a frontend rewrite. Start by auditing the contract between the source system and the browser.&lt;/p&gt;

&lt;p&gt;If the client is still cleaning, grouping, or interpolating raw records, move that work upstream. If requests are unbounded, introduce hard limits around time range, viewport, and detail tier. If the same expensive transformations happen per request, materialize them into artifacts. If cache invalidation still depends on instinct, add explicit revisioning.&lt;/p&gt;

&lt;p&gt;Most importantly, stop thinking of the backend as a passive row supplier. In data-heavy visual products, the backend is part of the user experience. It decides whether the page feels immediate or sticky, truthful or misleading, durable or demo-grade.&lt;/p&gt;

&lt;p&gt;The rule of thumb is simple: &lt;strong&gt;build the backend as if the frontend were a playback client for a versioned data product&lt;/strong&gt;. Because at scale, that is exactly what it is.&lt;/p&gt;

&lt;p&gt;Once you adopt that framing, the priorities stop being fuzzy. Preprocess aggressively. Keep payloads small. Cache by normalized request shape. Load detail progressively. Measure correctness as seriously as latency.&lt;/p&gt;

&lt;p&gt;That is how you build interactive data visualizations that still hold up after the launch post, not just during it.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/interactive-data-visualizations-need-backend-thinking-too/" rel="noopener noreferrer"&gt;https://qcode.in/interactive-data-visualizations-need-backend-thinking-too/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>datavis</category>
      <category>performance</category>
    </item>
    <item>
      <title>Where PgDog Fits in Real Postgres App Architectures</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Mon, 15 Jun 2026 04:56:31 +0000</pubDate>
      <link>https://dev.to/saqueib/where-pgdog-fits-in-real-postgres-app-architectures-2830</link>
      <guid>https://dev.to/saqueib/where-pgdog-fits-in-real-postgres-app-architectures-2830</guid>
      <description>&lt;p&gt;Postgres routing becomes an application problem much earlier than most teams admit. The database is still the database, but the moment you add replicas, pooling, failover, or even the possibility of sharding, your app stops talking to a single server and starts depending on traffic policy. That policy decides where reads go, what happens inside transactions, how degraded mode works, and whether queue workers behave the same way as HTTP requests.&lt;/p&gt;

&lt;p&gt;That is the real case for &lt;a href="https://docs.pgdog.dev/" rel="noopener noreferrer"&gt;PgDog&lt;/a&gt;. It is not just another Postgres tool with a nicer landing page. It is a sign that some teams have outgrown the fiction that routing can stay tucked inside ORM config forever.&lt;/p&gt;

&lt;p&gt;My view is straightforward: &lt;strong&gt;PgDog is interesting when you want routing policy to become infrastructure instead of framework behavior&lt;/strong&gt;. If you mostly need connection pooling, stick with &lt;a href="https://www.pgbouncer.org/" rel="noopener noreferrer"&gt;PgBouncer&lt;/a&gt;. If reads, replicas, failover, and consistency rules are already leaking into Laravel services, Node repositories, workers, and scripts, a smarter routing layer starts to make architectural sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres Routing Is Usually Hidden Technical Debt
&lt;/h2&gt;

&lt;p&gt;A lot of systems reach for database scaling through the wrong mental model. Teams think they are solving a throughput problem in Postgres, when they are actually creating coordination problems in the app.&lt;/p&gt;

&lt;p&gt;The first symptom is easy to recognize: too many connections. PHP-FPM workers spike, queue consumers scale out, background jobs open short-lived sessions, and Postgres starts wasting resources on connection churn instead of query execution. That is the point where people add pooling and feel smart for a week.&lt;/p&gt;

&lt;p&gt;The second symptom is more dangerous: now there is a primary, one or more replicas, and some rough rule like "reads go here, writes go there." The app starts carrying that rule around. Sometimes it lives in a database abstraction layer. Sometimes it leaks into custom repository code. Sometimes it becomes tribal knowledge like "this endpoint must force primary because the replica lags after checkout."&lt;/p&gt;

&lt;p&gt;That is where architecture drifts. Your HTTP app might know how to route a safe read. Your queue worker may not. Your reporting job may bypass the app container entirely and connect directly. Your migration scripts may ignore the same rules. The result is not one database architecture. It is several inconsistent ones wearing the same brand name.&lt;/p&gt;

&lt;p&gt;This is why routing matters more than it sounds. &lt;strong&gt;Once the application knows too much about topology, every runtime becomes a potential split-brain of behavior.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A cleaner mental model is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the app should express business intent&lt;/li&gt;
&lt;li&gt;the infrastructure should own topology intent&lt;/li&gt;
&lt;li&gt;the boundary between the two should stay explicit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That boundary is exactly what a proxy or routing layer tries to formalize.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Native Postgres Clients Already Give You, and What They Do Not
&lt;/h2&gt;

&lt;p&gt;Before adding any proxy, it is worth being honest about what the official Postgres stack can already do.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;libpq&lt;/code&gt; connection layer supports multiple hosts, &lt;code&gt;target_session_attrs&lt;/code&gt;, and &lt;code&gt;load_balance_hosts&lt;/code&gt;; the PostgreSQL docs describe how clients can select a primary, prefer a standby, or randomize host choice during connection establishment: &lt;a href="https://www.postgresql.org/docs/current/libpq-connect.html" rel="noopener noreferrer"&gt;libpq connection parameters&lt;/a&gt;. That is useful, and many teams underuse it.&lt;/p&gt;

&lt;p&gt;If all you need is resilient connection establishment, you can often get farther than expected with plain client features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;host=db-primary,db-replica-1,db-replica-2
port=5432,5432,5432
target_session_attrs=read-write
load_balance_hosts=random
connect_timeout=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That kind of setup helps with a few important things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;choosing a writable node when several hosts are listed&lt;/li&gt;
&lt;li&gt;preferring standby servers for specific read-only clients&lt;/li&gt;
&lt;li&gt;failing over without hardcoding a single IP everywhere&lt;/li&gt;
&lt;li&gt;reducing dependency on DNS tricks that age badly during incidents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But there is a hard limit here: &lt;strong&gt;native client host selection happens at connection time, not at query time&lt;/strong&gt;. Once a client connects, every subsequent query stays on that backend until the connection is dropped or recycled.&lt;/p&gt;

&lt;p&gt;That means client-native features do not solve the actual routing problems most growing apps run into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;splitting reads and writes behind a single endpoint&lt;/li&gt;
&lt;li&gt;ensuring workers, web requests, and scripts follow the same rules&lt;/li&gt;
&lt;li&gt;handling query-level edge cases like locking reads or write CTEs&lt;/li&gt;
&lt;li&gt;centralizing failover and replica traffic policy&lt;/li&gt;
&lt;li&gt;creating a path toward sharding without rewriting application connection logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the question is not whether Postgres clients can already do failover-aware connections. They can. The question is whether your team needs &lt;strong&gt;query-aware routing policy&lt;/strong&gt; rather than better connection strings.&lt;/p&gt;

&lt;p&gt;If the answer is no, do not add a proxy. If the answer is yes, pretending the ORM can absorb all of that cleanly is where bad architecture starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where PgDog Actually Fits
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.pgdog.dev/" rel="noopener noreferrer"&gt;PgDog&lt;/a&gt; positions itself as a Postgres connection pooler, load balancer, and sharding proxy. The interesting part is not the label. The interesting part is that its load balancer works by understanding Postgres queries, not just by choosing a backend once and tunneling blindly.&lt;/p&gt;

&lt;p&gt;According to the official load-balancer docs, PgDog can inspect incoming SQL, send plain &lt;code&gt;SELECT&lt;/code&gt; traffic to replicas, route writes to the primary, and handle important edge cases like &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; and write-producing CTEs: &lt;a href="https://docs.pgdog.dev/features/load-balancer/" rel="noopener noreferrer"&gt;PgDog load balancer overview&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That sounds like a convenience feature until you compare it with what application-managed splitting usually looks like in practice.&lt;/p&gt;

&lt;p&gt;In many apps, read/write splitting starts with code like this:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;$connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$isWrite&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'pgsql-primary'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'pgsql-replica'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&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;connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$connection&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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;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;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks harmless. It is also the beginning of a long maintenance bill.&lt;/p&gt;

&lt;p&gt;The bill arrives in stages:&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 1: You duplicate routing decisions
&lt;/h3&gt;

&lt;p&gt;A Laravel app grows one set of routing helpers. A Node service grows another. Queue workers do something slightly different. CLI tasks bypass both. Nobody notices because the happy path still works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2: Consistency bugs become context-dependent
&lt;/h3&gt;

&lt;p&gt;A user updates billing details and gets redirected to a page that reads from a lagging replica. Support cannot reproduce it reliably because it only happens after writes and only on one request path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3: Incident behavior becomes arbitrary
&lt;/h3&gt;

&lt;p&gt;A replica slows down or disappears. One service spills to primary. Another keeps timing out. A reporting worker hammers the only healthy node because its routing logic was copied from an old script.&lt;/p&gt;

&lt;p&gt;That is the point where a routing layer becomes less about convenience and more about removing policy from application code.&lt;/p&gt;

&lt;p&gt;The strongest case for PgDog is not "our database is huge." It is &lt;strong&gt;"our topology policy must be enforced consistently across many clients, and we no longer trust each app runtime to get it right on its own."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  PgBouncer Versus PgDog Is a Scope Decision, Not a Hype Decision
&lt;/h2&gt;

&lt;p&gt;Most teams evaluating PgDog should first ask whether they really just need PgBouncer.&lt;/p&gt;

&lt;p&gt;PgBouncer is still the cleaner answer for a huge class of systems. If the main pain is connection churn, backend pressure from too many app processes, or the need for lightweight pooling, PgBouncer remains hard to beat. It is focused, proven, and easier to reason about operationally.&lt;/p&gt;

&lt;p&gt;Its limits are also well documented. The official PgBouncer feature matrix makes it clear that transaction pooling changes client expectations in important ways. Session-level features like &lt;code&gt;SET/RESET&lt;/code&gt;, &lt;code&gt;LISTEN&lt;/code&gt;, session advisory locks, and some temp-table behavior do not behave the same way there: &lt;a href="https://www.pgbouncer.org/features.html" rel="noopener noreferrer"&gt;PgBouncer features&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That tradeoff is often acceptable. Many web apps can live with it. But it is solving a different class of problem.&lt;/p&gt;

&lt;p&gt;Here is the practical comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  PgBouncer is the right answer when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;your primary pain is too many connections&lt;/li&gt;
&lt;li&gt;your topology is still simple&lt;/li&gt;
&lt;li&gt;read routing is limited or already explicit in the app&lt;/li&gt;
&lt;li&gt;you want the fewest moving parts between app and database&lt;/li&gt;
&lt;li&gt;you are not ready to own query-aware proxy behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  PgDog becomes more compelling when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;you want one Postgres endpoint that hides topology from apps&lt;/li&gt;
&lt;li&gt;you need read/write routing across more than one runtime&lt;/li&gt;
&lt;li&gt;your worker processes and CLI jobs must behave like the web app&lt;/li&gt;
&lt;li&gt;failover behavior should be policy-driven, not framework-driven&lt;/li&gt;
&lt;li&gt;sharding is realistic enough to influence today’s connection strategy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters. Even if you are not sharding now, teams often make a mess by baking one-host assumptions deep into every application process. A proxy can buy optionality. The mistake is paying that complexity tax before the problem is real.&lt;/p&gt;

&lt;p&gt;My bias is conservative here. &lt;strong&gt;Do not add PgDog because it sounds more future-proof. Add it because your current routing behavior is already fragmented enough to justify centralization.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Design Work Is in Failure Modes
&lt;/h2&gt;

&lt;p&gt;Any routing layer looks good when replicas are healthy, lag is low, and traffic is predictable. The decision gets real when the system is under stress.&lt;/p&gt;

&lt;p&gt;The PgDog documentation is useful precisely because it exposes some of the awkward reality instead of pretending all &lt;code&gt;SELECT&lt;/code&gt; queries are equal. A query may begin with &lt;code&gt;SELECT&lt;/code&gt; and still be operationally part of a write path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example 1: Locking reads are not replica reads
&lt;/h3&gt;

&lt;p&gt;A common queue pattern looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&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;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your routing model is based on string matching or ORM-level intuition, this is where it breaks. The first statement reads rows, but it also acquires locks and participates directly in a write workflow. PgDog explicitly documents &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; handling because a serious routing layer has to understand that this belongs on primary.&lt;/p&gt;

&lt;p&gt;If your app owns this logic in scattered code, you must trust every implementation path to remember the same nuance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example 2: A &lt;code&gt;SELECT&lt;/code&gt; can still write
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email_change'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&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;u&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="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&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;AS&lt;/span&gt; &lt;span class="n"&gt;audit_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&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="n"&gt;u&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the kind of query that defeats simplistic read/write splitting. It reads like a report, but the CTE mutates state. PgDog’s docs call out write CTE inspection because real SQL is more subtle than "verb at the start of the statement."&lt;/p&gt;

&lt;h3&gt;
  
  
  Replica lag is not a footnote
&lt;/h3&gt;

&lt;p&gt;Even a clever proxy cannot remove asynchronous replication lag. If your system writes on primary and immediately reads dependent state from a replica, you have an application-level consistency decision to make.&lt;/p&gt;

&lt;p&gt;That decision should be explicit. Some flows must read from primary after a write:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;checkout and payment confirmation&lt;/li&gt;
&lt;li&gt;authentication or role changes&lt;/li&gt;
&lt;li&gt;inventory and stock reservation&lt;/li&gt;
&lt;li&gt;any flow where stale reads create user-visible contradiction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other flows can often tolerate replica reads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dashboards&lt;/li&gt;
&lt;li&gt;reporting&lt;/li&gt;
&lt;li&gt;search result decoration&lt;/li&gt;
&lt;li&gt;analytics and internal back-office views&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure is not using replicas. The failure is pretending every read has the same correctness requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  Degraded mode must be designed before the outage
&lt;/h3&gt;

&lt;p&gt;PgDog documents options around whether the primary also serves reads and whether it can temporarily absorb read traffic when replicas are unavailable. That is operationally useful. It also forces a real decision: when replicas fail, do you want stale partial service, slower correct service, or hard failure for some classes of traffic?&lt;/p&gt;

&lt;p&gt;There is no universal right answer.&lt;/p&gt;

&lt;p&gt;A product team usually needs to decide among patterns like these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fail open to primary.&lt;/strong&gt; Read traffic spills to primary so the app stays functional, but the write node risks overload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed for replica-only classes.&lt;/strong&gt; Some reporting or low-priority endpoints degrade or disable cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed policy.&lt;/strong&gt; Critical user flows fall back to primary; nonessential workloads shed load or queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is not just a database choice. It is product behavior under stress. A proxy can enforce the policy, but it cannot invent it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Full Stack Teams Should Decide Before Inserting a Routing Layer
&lt;/h2&gt;

&lt;p&gt;Adding PgDog without making these decisions first is how teams create a more sophisticated mess.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Define which flows require read-your-write guarantees
&lt;/h3&gt;

&lt;p&gt;Do not wave this away with "we use replicas for reads." Map the actual product surfaces. If a user changes something and expects the next screen to reflect it immediately, that path probably cannot rely on asynchronous replicas.&lt;/p&gt;

&lt;p&gt;This is especially relevant in Laravel and Node stacks that mix synchronous user requests with async jobs. A queue worker may update state that a web request reads a second later. If that request is replica-routed by default, you have just built inconsistency into the UX.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Standardize the endpoint story
&lt;/h3&gt;

&lt;p&gt;If you adopt PgDog, the win is consistency. The app should stop encoding topology in ten places.&lt;/p&gt;

&lt;p&gt;A Node service should be able to treat the database as a stable logical endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PGDOG_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6432&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadAccountSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`
      select id, email, plan, created_at
      from accounts
      where id = $1
    `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;accountId&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="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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 is a better abstraction boundary than wiring separate read and write DSNs into every service and hoping developers always choose correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Decide how transactions interact with routing
&lt;/h3&gt;

&lt;p&gt;Manually started transactions should usually stay boring. Once a unit of work spans several statements, the cost of clever routing often outweighs the benefit. Most teams are better off treating explicit transactions as primary-bound unless they have a very specific reason not to.&lt;/p&gt;

&lt;p&gt;That is one reason proxy-level routing can be safer than app-level heuristics. The routing rules can remain conservative by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Be honest about observability
&lt;/h3&gt;

&lt;p&gt;A routing layer with poor observability is a blame machine. If latency jumps, you need to know whether the problem is the proxy, a replica, the primary, a specific query class, or the app pool behavior.&lt;/p&gt;

&lt;p&gt;At minimum, teams should expect visibility into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;backend health and ban state&lt;/li&gt;
&lt;li&gt;route-level latency and error rate&lt;/li&gt;
&lt;li&gt;replica saturation versus primary saturation&lt;/li&gt;
&lt;li&gt;spillover behavior during degraded mode&lt;/li&gt;
&lt;li&gt;connection pool pressure and queueing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you cannot observe those, adding a proxy may make incident response worse before it makes architecture better.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Treat sharding as a roadmap signal, not a vanity signal
&lt;/h3&gt;

&lt;p&gt;PgDog’s broader promise includes sharding. That matters only if your domain model, tenancy boundaries, or growth path make it plausible. Do not let hypothetical future sharding justify present-day complexity on its own.&lt;/p&gt;

&lt;p&gt;But if your team already expects tenant partitioning, regional data placement, or hot-spot isolation to matter later, it is reasonable to prefer an access layer that does not force every client to relearn topology from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Sensible Adoption Path
&lt;/h2&gt;

&lt;p&gt;The best way to adopt a smarter routing layer is not with a big-bang cutover.&lt;/p&gt;

&lt;p&gt;Start simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stabilize connection behavior first&lt;/li&gt;
&lt;li&gt;document consistency-sensitive flows&lt;/li&gt;
&lt;li&gt;measure read volume that is actually safe to offload&lt;/li&gt;
&lt;li&gt;introduce the proxy for one service class or environment&lt;/li&gt;
&lt;li&gt;validate degraded-mode behavior before calling it production-ready&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A configuration skeleton might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[general]&lt;/span&gt;
&lt;span class="py"&gt;load_balancing_strategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"round_robin"&lt;/span&gt;
&lt;span class="py"&gt;read_write_split&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"exclude_primary"&lt;/span&gt;

&lt;span class="nn"&gt;[[databases]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"app"&lt;/span&gt;
&lt;span class="py"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"primary"&lt;/span&gt;
&lt;span class="py"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10.0.0.10"&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;

&lt;span class="nn"&gt;[[databases]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"app"&lt;/span&gt;
&lt;span class="py"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"replica"&lt;/span&gt;
&lt;span class="py"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10.0.0.11"&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;

&lt;span class="nn"&gt;[[databases]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"app"&lt;/span&gt;
&lt;span class="py"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"replica"&lt;/span&gt;
&lt;span class="py"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10.0.0.12"&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not the hard part. The hard part is validating whether your app semantics match the routing policy you just declared.&lt;/p&gt;

&lt;p&gt;A team that has not mapped stale-read tolerance, transaction expectations, and fallback behavior is not deploying a smart proxy. It is outsourcing confusion to infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Decision Rule
&lt;/h2&gt;

&lt;p&gt;PgDog makes sense when Postgres routing has already escaped the database team and started shaping application design. If replicas, failover, and traffic policy are leaking into repositories, workers, and framework config, centralizing those rules is a good architectural move.&lt;/p&gt;

&lt;p&gt;If your problem is still mostly connection count, choose PgBouncer and keep the stack smaller. If your problem is now &lt;strong&gt;policy consistency across many clients&lt;/strong&gt;, PgDog is the more serious tool.&lt;/p&gt;

&lt;p&gt;The rule of thumb is simple: &lt;strong&gt;add a routing layer when topology has become part of product behavior, not just infrastructure trivia&lt;/strong&gt;. At that point, hiding the problem inside app code is usually the more expensive choice.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/pgdog-smarter-postgres-routing-apps/" rel="noopener noreferrer"&gt;https://qcode.in/pgdog-smarter-postgres-routing-apps/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>laravel</category>
      <category>node</category>
    </item>
    <item>
      <title>Eloquent Filtering Gets Messy Fast. Here’s What Holds Up.</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Thu, 11 Jun 2026 07:34:36 +0000</pubDate>
      <link>https://dev.to/saqueib/eloquent-filtering-gets-messy-fast-heres-what-holds-up-50</link>
      <guid>https://dev.to/saqueib/eloquent-filtering-gets-messy-fast-heres-what-holds-up-50</guid>
      <description>&lt;p&gt;Flexible Eloquent filtering usually dies the same way: a clean &lt;code&gt;index()&lt;/code&gt; endpoint turns into fifteen &lt;code&gt;if ($request-&amp;gt;filled(...))&lt;/code&gt; branches, three &lt;code&gt;whereHas()&lt;/code&gt; chains, a half-working date range, and one emergency &lt;code&gt;orWhere()&lt;/code&gt; that quietly breaks tenant isolation.&lt;/p&gt;

&lt;p&gt;The fix is not “use a filtering package” or “keep it simple.” The real fix is choosing the &lt;strong&gt;right filtering shape for the kind of API you are building&lt;/strong&gt;. Laravel gives you enough rope here. Eloquent scopes, query objects, package-driven filters, JSON filters, and relationship filters all work. They just stop working at different complexity levels.&lt;/p&gt;

&lt;p&gt;My recommendation is straightforward: &lt;strong&gt;use local scopes for reusable model-level constraints, use explicit query objects for serious internal APIs, and use a package like &lt;a href="https://spatie.be/docs/laravel-query-builder/v7/introduction" rel="noopener noreferrer"&gt;Spatie Query Builder&lt;/a&gt; only when you actually want request-driven filtering semantics at the HTTP boundary&lt;/strong&gt;. Do not let controller methods become your query language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the failure mode, not the syntax
&lt;/h2&gt;

&lt;p&gt;Most teams don’t choose query spaghetti on purpose. They get there incrementally.&lt;/p&gt;

&lt;p&gt;A product listing starts with status and category filters. Then search gets added. Then tags. Then "only products with active discounts." Then sorting. Then a partner integration wants &lt;code&gt;created_from&lt;/code&gt; and &lt;code&gt;created_to&lt;/code&gt;. Then someone asks for &lt;code&gt;?brand=nike,adidas&lt;/code&gt; because that feels natural in a URL.&lt;/p&gt;

&lt;p&gt;The code often ends up looking like this:&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="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="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&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;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;()&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filled&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;string&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="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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&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;$q&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;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&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;'category'&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="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'search'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$search&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;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'search'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$query&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&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;$search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$q&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;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$search&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;orWhere&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="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$search&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'discounted'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'discounts'&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;$q&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;$q&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;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_from'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_from'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_to'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_to'&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;ProductResource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$query&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;paginate&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;Nothing in that method is individually outrageous. The problem is architectural. The controller is now doing four jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;parsing HTTP input&lt;/li&gt;
&lt;li&gt;defining business filtering rules&lt;/li&gt;
&lt;li&gt;composing SQL constraints&lt;/li&gt;
&lt;li&gt;hiding edge cases nobody wants to touch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That structure is cheap on day one and expensive forever after. It gets harder to test, easier to break, and nearly impossible to reuse from jobs, commands, admin screens, or other services.&lt;/p&gt;

&lt;p&gt;The first decision, then, is not which package to install. It is this: &lt;strong&gt;is your filtering logic request-shaped, domain-shaped, or both?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Scopes are excellent until they become a language
&lt;/h2&gt;

&lt;p&gt;Laravel’s &lt;a href="https://laravel.com/docs/13.x/eloquent#local-scopes" rel="noopener noreferrer"&gt;local scopes&lt;/a&gt; are still the cleanest starting point for common reusable constraints. If you have one meaningful business concept, a scope is usually the right answer.&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;Product&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Scope]&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&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;$query&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Scope]&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;forTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&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;$tenantId&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;$query&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;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Scope]&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;inCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="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 gives you readable query code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Product&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;forTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tenantId&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;active&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;inCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'laptops'&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;paginate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where scopes win:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the condition has a clear domain meaning&lt;/li&gt;
&lt;li&gt;you will reuse it in multiple places&lt;/li&gt;
&lt;li&gt;it composes cleanly with other builder calls&lt;/li&gt;
&lt;li&gt;the parameter shape is small and obvious&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where scopes start losing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you need ten optional filters from one request&lt;/li&gt;
&lt;li&gt;filters interact with each other&lt;/li&gt;
&lt;li&gt;you need branching logic based on user role, feature flags, or API version&lt;/li&gt;
&lt;li&gt;you are effectively inventing a mini query DSL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A scope should represent &lt;strong&gt;one stable concept&lt;/strong&gt;, not half a search form. &lt;code&gt;active()&lt;/code&gt;, &lt;code&gt;forTenant()&lt;/code&gt;, &lt;code&gt;visibleToUser()&lt;/code&gt; and &lt;code&gt;withPublishedPosts()&lt;/code&gt; are good scopes. &lt;code&gt;filterFromRequest()&lt;/code&gt; is a code smell wearing Laravel clothes.&lt;/p&gt;

&lt;p&gt;Another common mistake is pushing too much parsing into scopes. If a scope is exploding CSV values, reading request keys, and inferring boolean semantics, you are no longer modeling data access cleanly. You are smuggling transport concerns into the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; if the scope name reads well in a sentence, it probably belongs. If it reads like URL syntax, it probably does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query objects are the best default for serious APIs
&lt;/h2&gt;

&lt;p&gt;Once an endpoint supports multiple optional filters, explicit query objects usually beat every other pattern on maintainability.&lt;/p&gt;

&lt;p&gt;Why? Because they separate responsibilities cleanly. The request object validates input. A filter DTO normalizes it. A query object applies database constraints. Your controller becomes boring again, which is what you want.&lt;/p&gt;

&lt;p&gt;Here is a practical shape that holds up well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductFilters&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;?string&lt;/span&gt; &lt;span class="nv"&gt;$status&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;?string&lt;/span&gt; &lt;span class="nv"&gt;$category&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;?string&lt;/span&gt; &lt;span class="nv"&gt;$search&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;?bool&lt;/span&gt; &lt;span class="nv"&gt;$discounted&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;?Carbon&lt;/span&gt; &lt;span class="nv"&gt;$createdFrom&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;?Carbon&lt;/span&gt; &lt;span class="nv"&gt;$createdTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;fromRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&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="nv"&gt;$request&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;'status'&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;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;category&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;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&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;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;search&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;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'search'&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;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;discounted&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;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'discounted'&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;boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'discounted'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;createdFrom&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="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_from'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;createdTo&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="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_to'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductIndexQuery&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;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ProductFilters&lt;/span&gt; &lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Builder&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;$query&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;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;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&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;$q&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&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;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;category&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$category&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$categoryQuery&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;$categoryQuery&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$category&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;search&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;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$q&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$nested&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;$search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$nested&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;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$search&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;orWhere&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="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$search&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;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;discounted&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'discounts'&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$discounts&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;$discounts&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;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;createdFrom&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$date&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$date&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;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;createdTo&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$date&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$date&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;Then your controller becomes small enough to trust:&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="kt"&gt;ProductIndexRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ProductIndexQuery&lt;/span&gt; &lt;span class="nv"&gt;$productIndexQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProductFilters&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$productIndexQuery&lt;/span&gt;
        &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;apply&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;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;forTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;()&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="nv"&gt;$filters&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;paginate&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;ProductResource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$products&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;This approach is not flashy. It is better than flashy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why query objects hold up better
&lt;/h3&gt;

&lt;p&gt;First, they scale in plain PHP. You can unit test the filter object, feature test the endpoint, and integration test SQL-sensitive paths without pretending the controller is an architecture.&lt;/p&gt;

&lt;p&gt;Second, they make &lt;strong&gt;unsafe logic easier to see&lt;/strong&gt;. A loose &lt;code&gt;orWhere()&lt;/code&gt; hidden inside nested conditions is much easier to spot in a dedicated query class than inside a 120-line action method.&lt;/p&gt;

&lt;p&gt;Third, they are reusable. The same query object can power an API endpoint, an admin table, an export job, or a queued report.&lt;/p&gt;

&lt;p&gt;If your team builds internal tools, back-office dashboards, or domain-heavy SaaS APIs, query objects are the most boring sustainable answer. That is a compliment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Package-based filtering is great at the boundary, not in the core
&lt;/h2&gt;

&lt;p&gt;If your API is intentionally request-driven, &lt;a href="https://spatie.be/docs/laravel-query-builder/v7/features/filtering" rel="noopener noreferrer"&gt;Spatie Laravel Query Builder&lt;/a&gt; is one of the few packages I would recommend without hand-waving. It gives you a controlled way to expose filtering, sorting, and includes from HTTP query parameters.&lt;/p&gt;

&lt;p&gt;That matters because the package forces one discipline most homegrown filtering layers never get right: &lt;strong&gt;an explicit allowlist&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\QueryBuilder\AllowedFilter&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;Spatie\QueryBuilder\QueryBuilder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QueryBuilder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&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="n"&gt;class&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;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;()&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allowedFilters&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nc"&gt;AllowedFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;exact&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="nc"&gt;AllowedFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;AllowedFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&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;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nc"&gt;AllowedFilter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'search'&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;Builder&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$query&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$nested&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;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$nested&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;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$value&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;orWhere&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="s1"&gt;'like'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$value&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;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allowedSorts&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;'price'&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;defaultSort&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For public or partner-facing APIs, that is a strong trade. You get predictable syntax like &lt;code&gt;?filter[status]=active&lt;/code&gt;, explicit surface area, and less controller glue.&lt;/p&gt;

&lt;p&gt;But packages like this are not magic. They are best when the &lt;strong&gt;request itself is the product&lt;/strong&gt;. They are less compelling when your filtering rules are mostly domain logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where package filtering wins
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;public APIs with documented query parameters&lt;/li&gt;
&lt;li&gt;index endpoints with many optional filters&lt;/li&gt;
&lt;li&gt;teams that want consistency across resources&lt;/li&gt;
&lt;li&gt;cases where sort/include/filter conventions should feel uniform&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where it becomes awkward
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;deeply coupled business rules&lt;/li&gt;
&lt;li&gt;nontrivial authorization-driven filtering&lt;/li&gt;
&lt;li&gt;multi-step query assembly across services&lt;/li&gt;
&lt;li&gt;highly custom search behavior that stops looking like simple request filters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also a real footgun here: once the package feels convenient, teams start exposing more surface area than they should. Just because you &lt;em&gt;can&lt;/em&gt; allow a field or relation does not mean the endpoint should support it.&lt;/p&gt;

&lt;p&gt;The package should not become permission to turn your database schema into your API contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strong recommendation:&lt;/strong&gt; use package filtering at the HTTP edge, then keep domain-critical query logic behind scopes, query objects, or dedicated callbacks. Let the package route requests. Do not let it design your data access model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relationship and JSON filters are where clean code usually rots
&lt;/h2&gt;

&lt;p&gt;This is the part most filtering tutorials underplay. Filtering gets ugly fastest when it crosses relationships or dives into semi-structured JSON columns.&lt;/p&gt;

&lt;p&gt;Relationship filters with &lt;code&gt;whereHas()&lt;/code&gt; are powerful, but they are also where query performance, readability, and accidental logic leaks start showing up.&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;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereHas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders'&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;Builder&lt;/span&gt; &lt;span class="nv"&gt;$orders&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;$filters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$orders&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;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'paid'&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;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;paidFrom&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$date&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'paid_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$date&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;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$filters&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;paidTo&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="kt"&gt;Builder&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Carbon&lt;/span&gt; &lt;span class="nv"&gt;$date&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;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'paid_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$date&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 is fine when it is deliberate. It becomes a mess when every new filter adds another nested closure and nobody reviews the resulting SQL.&lt;/p&gt;

&lt;p&gt;JSON filters are even worse when used lazily. Yes, MySQL and PostgreSQL can query JSON. Yes, Eloquent supports JSON path syntax. That does not mean arbitrary user-facing filtering should live there.&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;$query&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;'settings-&amp;gt;notifications-&amp;gt;email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereJsonContains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settings-&amp;gt;roles'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'editor'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is acceptable for a few stable flags. It is not a good foundation for a broad filtering API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why these filters become maintenance traps
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;they hide indexing problems until production traffic arrives&lt;/li&gt;
&lt;li&gt;they make database portability harder&lt;/li&gt;
&lt;li&gt;they encourage schema avoidance instead of schema design&lt;/li&gt;
&lt;li&gt;they are easy to compose badly with &lt;code&gt;orWhere&lt;/code&gt; and nested conditions&lt;/li&gt;
&lt;li&gt;they are harder to explain and document than normal relational filters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a filter matters enough to be exposed, measured, and depended on, it usually deserves a real column, a real relation, or a real query object path. JSON is a storage format, not a long-term filtering strategy.&lt;/p&gt;

&lt;p&gt;The same principle applies to relationship-heavy filters. If your endpoint needs repeated cross-relation filtering, stop sprinkling &lt;code&gt;whereHas()&lt;/code&gt; everywhere and give that behavior a name. Hide it behind a scope or query object method that states intent clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical selection rule
&lt;/h2&gt;

&lt;p&gt;You do not need one filtering pattern. You need a &lt;strong&gt;selection rule&lt;/strong&gt; that keeps your codebase consistent.&lt;/p&gt;

&lt;p&gt;Here is the rule I would use on a Laravel team today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;strong&gt;local scopes&lt;/strong&gt; for small, reusable, domain-named constraints.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;query objects&lt;/strong&gt; when an endpoint has several optional filters or meaningful business rules.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Spatie Query Builder&lt;/strong&gt; when the API contract itself is request-driven and you want explicit HTTP filtering semantics.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;relationship filters&lt;/strong&gt; deliberately, but hide repeated ones behind named abstractions.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;JSON filters&lt;/strong&gt; sparingly, and treat them as a compromise, not a default.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you already have a spaghetti controller, do not rewrite everything at once. Pull it apart in this order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;move obvious reusable rules into scopes&lt;/li&gt;
&lt;li&gt;introduce a dedicated query object for the endpoint&lt;/li&gt;
&lt;li&gt;move request parsing into validation or a filter DTO&lt;/li&gt;
&lt;li&gt;only then decide whether package-level request filtering actually improves the API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sequence matters. Teams often install a package first because it feels like progress. In many codebases, the real problem is not missing syntax. It is missing boundaries.&lt;/p&gt;

&lt;p&gt;The best Eloquent filtering code is not the most clever. It is the code that still makes sense six months later when someone adds one more filter under deadline pressure. If your controller is becoming a query language, you are already late. Split the responsibilities, keep the HTTP layer honest, and make your filtering strategy explicit before the endpoint turns into something nobody wants to touch.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/advanced-eloquent-filtering-without-turning-apis-into-query-spaghetti/" rel="noopener noreferrer"&gt;https://qcode.in/advanced-eloquent-filtering-without-turning-apis-into-query-spaghetti/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>eloquent</category>
      <category>api</category>
    </item>
    <item>
      <title>Laravel `Bus::bulk()`: Faster Dispatch, Harder Queue Tradeoffs</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Thu, 11 Jun 2026 04:08:06 +0000</pubDate>
      <link>https://dev.to/saqueib/laravel-busbulk-faster-dispatch-harder-queue-tradeoffs-c08</link>
      <guid>https://dev.to/saqueib/laravel-busbulk-faster-dispatch-harder-queue-tradeoffs-c08</guid>
      <description>&lt;p&gt;&lt;code&gt;Bus::bulk()&lt;/code&gt; is not a fancy alias for &lt;code&gt;dispatch()&lt;/code&gt; in a loop, and it is definitely not a lighter &lt;code&gt;Bus::batch()&lt;/code&gt;. In Laravel 13, it is a lower-level dispatch optimization for &lt;strong&gt;many independent jobs&lt;/strong&gt; where you care about enqueue efficiency more than workflow semantics. That can be a real win. It can also make a queue system harder to reason about if you adopt it for the wrong workload.&lt;/p&gt;

&lt;p&gt;My recommendation is simple: &lt;strong&gt;use &lt;code&gt;Bus::bulk()&lt;/code&gt; when the jobs are independent, idempotent, and high-volume, and when faster enqueueing actually matters.&lt;/strong&gt; Do not use it because the method name sounds more scalable. Bulk dispatch changes the shape of failure, observability, queue pressure, and recovery. If your team ignores those tradeoffs, the performance win is usually fake.&lt;/p&gt;

&lt;p&gt;Laravel introduced &lt;code&gt;Bus::bulk()&lt;/code&gt; in Laravel 13.13, and the official &lt;a href="https://laravel.com/docs/13.x/queues#bulk-dispatching" rel="noopener noreferrer"&gt;queue documentation&lt;/a&gt; is careful about its scope: it is for cases where you do not need batch tracking or callbacks. That line matters more than most people think.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;Bus::bulk()&lt;/code&gt; optimizes transport, not workflow
&lt;/h2&gt;

&lt;p&gt;The first mental model to fix is this: &lt;code&gt;Bus::bulk()&lt;/code&gt; is about &lt;strong&gt;how jobs are pushed&lt;/strong&gt;, not about &lt;strong&gt;how a larger business operation is managed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you call &lt;code&gt;dispatch()&lt;/code&gt; repeatedly, Laravel resolves and pushes each job individually. With &lt;code&gt;Bus::bulk()&lt;/code&gt;, Laravel groups jobs by their configured &lt;strong&gt;connection&lt;/strong&gt; and &lt;strong&gt;queue name&lt;/strong&gt;, then calls the queue driver's &lt;code&gt;bulk()&lt;/code&gt; implementation for each group. That means the behavior is partly framework-level and partly driver-specific.&lt;/p&gt;

&lt;p&gt;That distinction matters because not every queue backend gets the same benefit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The upside depends on the driver
&lt;/h3&gt;

&lt;p&gt;On Redis, bulk dispatch can reduce round-trips and make large fan-out workloads cheaper to enqueue. On the database driver, it can collapse many inserts into a single write operation, which can dramatically reduce application-side dispatch overhead.&lt;/p&gt;

&lt;p&gt;But on SQS, the improvement is much less exciting. Laravel's queue driver does not currently convert &lt;code&gt;Bus::bulk()&lt;/code&gt; into a single AWS &lt;code&gt;SendMessageBatch&lt;/code&gt; operation. It still iterates through jobs internally. So if you expected one bulk network call instead of thousands, that assumption does not hold.&lt;/p&gt;

&lt;p&gt;That is the first place teams overstate the benefit. &lt;code&gt;Bus::bulk()&lt;/code&gt; is not one universal optimization. It is a facade over different backend strategies.&lt;/p&gt;

&lt;h3&gt;
  
  
  It is intentionally weaker than batching
&lt;/h3&gt;

&lt;p&gt;The second mental model to fix is that &lt;code&gt;Bus::bulk()&lt;/code&gt; is not a workflow primitive. It gives you no built-in batch ID, no progress tracking, no completion callbacks, no cancellation semantics, and no batch-level failure handling.&lt;/p&gt;

&lt;p&gt;If your use case needs to answer questions like these, &lt;code&gt;Bus::bulk()&lt;/code&gt; is probably the wrong tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has the whole import finished?&lt;/li&gt;
&lt;li&gt;Which jobs from this run failed?&lt;/li&gt;
&lt;li&gt;When should the next phase start?&lt;/li&gt;
&lt;li&gt;Can we cancel the remaining work?&lt;/li&gt;
&lt;li&gt;Can the UI show progress for this operation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are &lt;code&gt;Bus::batch()&lt;/code&gt; questions, not &lt;code&gt;Bus::bulk()&lt;/code&gt; questions.&lt;/p&gt;

&lt;p&gt;That difference is why the method can be faster. Laravel is doing less coordination for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where bulk dispatch is genuinely useful
&lt;/h2&gt;

&lt;p&gt;There are real workloads where &lt;code&gt;Bus::bulk()&lt;/code&gt; is the right call. The best ones share a few traits: the jobs are independent, there are a lot of them, their payloads are small, and the producer is spending non-trivial time enqueueing work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Good fit: wide fan-out, independent work
&lt;/h3&gt;

&lt;p&gt;A classic example is search indexing, thumbnail generation, export assembly, cache warming, or syncing records to a third-party system where each item can succeed or fail on its own.&lt;/p&gt;

&lt;p&gt;If the jobs do not depend on each other, and there is no single coordinated "all done" step that needs framework support, bulk dispatch is a reasonable optimization.&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;App\Jobs\SyncProductToSearch&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\Support\Facades\Bus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$jobs&lt;/span&gt; &lt;span class="o"&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;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&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;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;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;$product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SyncProductToSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&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="nc"&gt;Bus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bulk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jobs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail is not the method call. The key detail is that each job only needs a product ID and can run without coordination with the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best fit: Redis-backed queues
&lt;/h3&gt;

&lt;p&gt;Redis is where &lt;code&gt;Bus::bulk()&lt;/code&gt; is easiest to justify. Redis handles high-throughput enqueueing well, and Laravel's implementation can reduce the chatter required to push a large number of jobs.&lt;/p&gt;

&lt;p&gt;If you have a command or scheduled task that creates tens of thousands of small jobs, the wall-clock difference between a plain loop and bulk dispatch can be noticeable. This is especially true when the enqueue path itself is part of a latency-sensitive operation, such as an admin action or a scheduled window with a hard runtime budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conditional fit: database queues
&lt;/h3&gt;

&lt;p&gt;The database queue can also benefit because its bulk path can insert many job rows in a single operation. That sounds attractive, and sometimes it is. But this is where teams need discipline.&lt;/p&gt;

&lt;p&gt;Database queues are usually fine for modest workloads, but they are not the backend I would choose for sustained high-volume fan-out. If you need &lt;code&gt;Bus::bulk()&lt;/code&gt; because you are generating a huge amount of work regularly, that is often a signal to revisit the backend first.&lt;/p&gt;

&lt;p&gt;A faster insert path does not remove the downstream cost of a relational database being your queue broker. It just lets you feed that broker harder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weak fit: SQS when you expect dramatic throughput gains
&lt;/h3&gt;

&lt;p&gt;SQS remains a good queue backend overall, but &lt;code&gt;Bus::bulk()&lt;/code&gt; is not a transport miracle there. If your main reason to adopt it is dispatch-time performance, benchmark first. You may still like the cleaner API, but the performance story is weaker than on Redis or the database driver.&lt;/p&gt;

&lt;p&gt;That matters because engineering teams tend to cargo-cult queue APIs. One developer sees a new method, assumes it is categorically faster, and starts replacing loops everywhere. That is not engineering. That is styling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden costs show up after dispatch, not during it
&lt;/h2&gt;

&lt;p&gt;The biggest trap with &lt;code&gt;Bus::bulk()&lt;/code&gt; is that it looks like a producer-side optimization, so people evaluate it only by enqueue timing. In practice, the harder problems show up later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Queue pressure gets worse faster
&lt;/h3&gt;

&lt;p&gt;Bulk dispatch makes it easier to create a backlog spike than to drain one. That is the trade.&lt;/p&gt;

&lt;p&gt;If your code can now enqueue 50,000 or 200,000 jobs in a burst, you have changed the operational shape of the system even if each job is unchanged. Redis memory can jump. The &lt;code&gt;jobs&lt;/code&gt; table can absorb a large write burst. Horizon dashboards can flatten into one huge queue mountain. More importantly, shared workers can become busy with bulk work while user-facing jobs wait.&lt;/p&gt;

&lt;p&gt;That means queue topology matters more once you adopt bulk dispatch.&lt;/p&gt;

&lt;p&gt;If you bulk-dispatch low-priority work onto the same queue as password emails, checkout webhooks, or billing callbacks, you are asking for user-visible regressions. The producer got faster. The system got less fair.&lt;/p&gt;

&lt;p&gt;A safer design usually means at least one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a dedicated queue for the bulk workload&lt;/li&gt;
&lt;li&gt;dedicated worker pools for that queue&lt;/li&gt;
&lt;li&gt;capped concurrency if the jobs hit a fragile dependency&lt;/li&gt;
&lt;li&gt;chunked dispatch instead of one giant burst&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Observability becomes something you have to rebuild
&lt;/h3&gt;

&lt;p&gt;With plain bulk dispatch, Laravel knows about individual jobs. It does not know much about the logical run that produced them unless you encode that yourself.&lt;/p&gt;

&lt;p&gt;That becomes painful the first time someone asks, "Which product reindex run caused these failures?" or "Did yesterday's CRM sync complete?"&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Bus::batch()&lt;/code&gt; gives you a first-class unit of work. &lt;code&gt;Bus::bulk()&lt;/code&gt; gives you raw job fan-out. If you want run-level visibility, you need to create it.&lt;/p&gt;

&lt;p&gt;The practical fix is to stamp correlation metadata into every job and log around that metadata consistently.&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;App\Jobs\PushInvoiceToCrm&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\Support\Facades\Bus&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\Support\Str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$runId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$invoices&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;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PushInvoiceToCrm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;invoiceId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$invoice&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="n"&gt;runId&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;triggeredBy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;auth&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;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;span class="nc"&gt;Bus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bulk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jobs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;runId&lt;/code&gt; should then flow through logs, failed job records, metrics, and any operational dashboards you care about. Without that, bulk workloads are much harder to debug than teams expect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure becomes fragmented by design
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;Bus::bulk()&lt;/code&gt;, partial success is not an edge case. It is normal.&lt;/p&gt;

&lt;p&gt;A queue worker can crash after processing some jobs. A deployment can restart workers mid-run. Downstream APIs can start rate limiting halfway through. A subset of jobs can serialize or deserialize differently due to stale assumptions in the payload. Some jobs can succeed while others fail and retry repeatedly.&lt;/p&gt;

&lt;p&gt;That is not a flaw in &lt;code&gt;Bus::bulk()&lt;/code&gt;. It is the consequence of choosing an independent-job model. But your application code needs to be honest about it.&lt;/p&gt;

&lt;p&gt;If the business operation actually requires coordinated all-or-nothing behavior, bulk dispatch is already the wrong abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real production requirement is idempotency
&lt;/h2&gt;

&lt;p&gt;If you adopt &lt;code&gt;Bus::bulk()&lt;/code&gt; without strong idempotency, you are building a queue system that only works on good days.&lt;/p&gt;

&lt;p&gt;This is the point most teams underinvest in. They focus on the dispatch API and ignore the job contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  What idempotency means here
&lt;/h3&gt;

&lt;p&gt;An idempotent job can run more than once without causing broken state, duplicate external effects, or corrupted accounting. That does not mean the code is literally side-effect free. It means the side effects are safe to repeat or safely de-duplicated.&lt;/p&gt;

&lt;p&gt;With bulk dispatch, idempotency matters even more because large bursts multiply the odds that you will eventually see duplicates, retries, replay scenarios, or ambiguous outcomes.&lt;/p&gt;

&lt;h3&gt;
  
  
  A bad bulk job
&lt;/h3&gt;

&lt;p&gt;A bad bulk job assumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it will only run once&lt;/li&gt;
&lt;li&gt;it will run after all related records are fully committed&lt;/li&gt;
&lt;li&gt;no other worker will race it&lt;/li&gt;
&lt;li&gt;no retry will hit the same side effect twice&lt;/li&gt;
&lt;li&gt;the external API will always accept a duplicate write cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That job may appear fine in staging. It will not stay fine in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  A better bulk job pattern
&lt;/h3&gt;

&lt;p&gt;A safer job keeps its payload small, reads fresh state inside &lt;code&gt;handle()&lt;/code&gt;, and uses an idempotent persistence pattern around the side effect.&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;App\Models\Invoice&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\Queue\Queueable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="no"&gt;Illuminate\Support\Facades\DB&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;PushInvoiceToCrm&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;Queueable&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;int&lt;/span&gt; &lt;span class="nv"&gt;$invoiceId&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;string&lt;/span&gt; &lt;span class="nv"&gt;$runId&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;?int&lt;/span&gt; &lt;span class="nv"&gt;$triggeredBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="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;CrmClient&lt;/span&gt; &lt;span class="nv"&gt;$crm&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;$invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&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;findOrFail&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;invoiceId&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;transaction&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;$invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$crm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'crm_sync_log'&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;'invoice_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$invoice&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="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;'run_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;runId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

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

            &lt;span class="nv"&gt;$remoteId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$crm&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;upsertInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"invoice:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$invoice&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;:run:&lt;/span&gt;&lt;span class="si"&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;runId&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="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'number'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'currency'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;currency&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="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'crm_sync_log'&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;updateOrInsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'invoice_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&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;'run_id'&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="n"&gt;runId&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="s1"&gt;'remote_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$remoteId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'synced'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'triggered_by'&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="n"&gt;triggeredBy&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="s1"&gt;'created_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="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;This job is not fancy. That is a good sign. It loads current state, uses a stable idempotency key, and records whether the logical unit of work already completed for the given run.&lt;/p&gt;

&lt;p&gt;That pattern is much more valuable than shaving a few milliseconds off dispatch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Payload discipline matters too
&lt;/h3&gt;

&lt;p&gt;Bulk-dispatched jobs should usually carry &lt;strong&gt;IDs and metadata&lt;/strong&gt;, not fat object graphs. Passing hydrated models is convenient, but it makes queue payloads heavier and makes serialization behavior more brittle.&lt;/p&gt;

&lt;p&gt;This matters more at scale. A queue system that handles 500 small payloads may behave very differently when asked to store 100,000 large serialized jobs. Bulk dispatch makes that mistake more expensive, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to keep bulk dispatch from overwhelming the system
&lt;/h2&gt;

&lt;p&gt;The safest pattern with &lt;code&gt;Bus::bulk()&lt;/code&gt; is usually not "collect everything and fire once." It is &lt;strong&gt;chunked bulk dispatch with queue isolation&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prefer chunked fan-out over one giant submission
&lt;/h3&gt;

&lt;p&gt;Chunking gives you three concrete benefits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It bounds memory usage in the producer.&lt;/li&gt;
&lt;li&gt;It reduces the blast radius of a dispatch-time failure.&lt;/li&gt;
&lt;li&gt;It lets the workers start draining while the producer is still generating more work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is often a much healthier operating pattern than one massive enqueue burst.&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;App\Jobs\GenerateStatementPdf&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\Customer&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\Support\Facades\Bus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Customer&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'needs_statement'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&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;chunkById&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="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$customers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$customers&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;$customer&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GenerateStatementPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$customer&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'statements'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Bus&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bulk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jobs&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;This pattern is operationally boring, which is what you want. It avoids building one enormous in-memory collection, and it gives your workers a steadier stream of work instead of a queue cliff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isolate bulk queues from user-facing queues
&lt;/h3&gt;

&lt;p&gt;If the workload is high-volume, do not share a queue with latency-sensitive jobs unless you have a very good reason.&lt;/p&gt;

&lt;p&gt;This is one of the easiest queue design mistakes to make in Laravel because the framework makes adding jobs so convenient. A bulk reindex run should not be able to delay password reset mail, checkout events, or subscription webhooks.&lt;/p&gt;

&lt;p&gt;Separate queue names are cheap. Worker isolation is cheaper than production incidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Align concurrency to the dependency, not the CPU
&lt;/h3&gt;

&lt;p&gt;A common failure mode is scaling workers based on server capacity while ignoring what the jobs actually hit.&lt;/p&gt;

&lt;p&gt;If the jobs call an external API with rate limits, increasing workers may only turn a manageable queue into a retry storm. If the jobs write to the same hot tables, more workers may just increase lock contention. If the jobs hit S3 or image processing, the bottleneck may sit in network bandwidth or disk I/O instead of PHP execution.&lt;/p&gt;

&lt;p&gt;Bulk dispatch forces this conversation because it can feed workers much faster. That is useful only when the rest of the pipeline is ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watch transaction boundaries
&lt;/h3&gt;

&lt;p&gt;Another subtle risk is dispatch timing relative to database commits. If you bulk-create jobs based on records that are still being written inside a transaction, workers may begin processing before the expected state is visible.&lt;/p&gt;

&lt;p&gt;That is not unique to &lt;code&gt;Bus::bulk()&lt;/code&gt;, but bulk dispatch makes the failure mode wider because many jobs can be enqueued at once.&lt;/p&gt;

&lt;p&gt;The practical rule is the same one good queue systems always follow: dispatch jobs &lt;strong&gt;after&lt;/strong&gt; the state they depend on is durably committed, or use after-commit semantics where appropriate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to benchmark before adopting it broadly
&lt;/h2&gt;

&lt;p&gt;A lot of queue articles stop at "bulk is faster." That is not enough. The only benchmark that matters is one that measures both enqueue cost and downstream impact.&lt;/p&gt;

&lt;p&gt;At minimum, compare a loop of &lt;code&gt;dispatch()&lt;/code&gt; calls against &lt;code&gt;Bus::bulk()&lt;/code&gt; using the same workload and the same backend. Measure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;time spent enqueueing&lt;/li&gt;
&lt;li&gt;time until the first job starts&lt;/li&gt;
&lt;li&gt;total drain time for the workload&lt;/li&gt;
&lt;li&gt;queue depth peak during the run&lt;/li&gt;
&lt;li&gt;failed jobs and retry volume&lt;/li&gt;
&lt;li&gt;Redis memory or database pressure during the spike&lt;/li&gt;
&lt;li&gt;whether other queues got slower while the bulk run was active&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Horizon, watch the whole system, not just the bulk queue. It is very common to cut producer time in half and quietly make unrelated queues worse.&lt;/p&gt;

&lt;p&gt;Also benchmark with realistic payloads. Small fake jobs can hide the real cost of serialization, storage, and downstream calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use &lt;code&gt;Bus::bulk()&lt;/code&gt;, &lt;code&gt;Bus::batch()&lt;/code&gt;, or plain dispatch
&lt;/h2&gt;

&lt;p&gt;The cleanest decision rule looks like this:&lt;/p&gt;

&lt;h3&gt;
  
  
  Use &lt;code&gt;Bus::bulk()&lt;/code&gt; when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;jobs are independent&lt;/li&gt;
&lt;li&gt;you have a lot of them&lt;/li&gt;
&lt;li&gt;dispatch overhead is measurable&lt;/li&gt;
&lt;li&gt;you do not need framework-level progress or callbacks&lt;/li&gt;
&lt;li&gt;each job is idempotent and safely retryable&lt;/li&gt;
&lt;li&gt;you are prepared to add your own correlation and observability&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Use &lt;code&gt;Bus::batch()&lt;/code&gt; when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;the workload is one logical operation&lt;/li&gt;
&lt;li&gt;you need progress tracking or completion callbacks&lt;/li&gt;
&lt;li&gt;you need a first-class batch ID&lt;/li&gt;
&lt;li&gt;cancellation and failure coordination matter&lt;/li&gt;
&lt;li&gt;the UI or ops team needs batch-level visibility&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Use plain &lt;code&gt;dispatch()&lt;/code&gt; in a loop when
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;the number of jobs is modest&lt;/li&gt;
&lt;li&gt;clarity beats micro-optimization&lt;/li&gt;
&lt;li&gt;the enqueue path is not a bottleneck&lt;/li&gt;
&lt;li&gt;you do not want driver-specific bulk behavior to shape the design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is worth underlining. A lot of teams should stay with plain &lt;code&gt;dispatch()&lt;/code&gt; longer than they think. Simpler code is often the better production choice when queue volume is still moderate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Bus::bulk()&lt;/code&gt; earns its place when the producer is demonstrably expensive, not when the method name sounds more scalable.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;Bus::bulk()&lt;/code&gt; is useful, but it is a sharp tool. It helps most when you already have a disciplined queue design: small payloads, idempotent jobs, isolated queues, realistic worker concurrency, and decent observability.&lt;/p&gt;

&lt;p&gt;If you do not have those things, bulk dispatch mostly gives you the ability to create bigger problems faster.&lt;/p&gt;

&lt;p&gt;So the production rule is this: &lt;strong&gt;bulk-dispatch only independent jobs, bulk-dispatch them in chunks, and never treat enqueue speed as the only success metric.&lt;/strong&gt; If you need coordinated workflow semantics, use &lt;code&gt;Bus::batch()&lt;/code&gt;. If you just need raw fan-out efficiency and the jobs are built correctly, &lt;code&gt;Bus::bulk()&lt;/code&gt; is the right tool.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/laravel-bus-bulk-when-bulk-dispatch-helps-hurts/" rel="noopener noreferrer"&gt;https://qcode.in/laravel-bus-bulk-when-bulk-dispatch-helps-hurts/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>queues</category>
      <category>performance</category>
    </item>
    <item>
      <title>Typed Eloquent boundaries without building a second ORM</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Mon, 08 Jun 2026 06:10:35 +0000</pubDate>
      <link>https://dev.to/saqueib/typed-eloquent-boundaries-without-building-a-second-orm-36g7</link>
      <guid>https://dev.to/saqueib/typed-eloquent-boundaries-without-building-a-second-orm-36g7</guid>
      <description>&lt;p&gt;Most Laravel teams do not need to "fix" Eloquent. They need to stop letting &lt;strong&gt;raw model state leak too far&lt;/strong&gt; into code that makes real business decisions.&lt;/p&gt;

&lt;p&gt;That is the practical version of this debate.&lt;/p&gt;

&lt;p&gt;Typed objects around Eloquent can be a big improvement, but only when they are used as &lt;strong&gt;boundaries&lt;/strong&gt;. If you push the pattern too far, you end up with a second object model shadowing the first one. At that point you are not improving Laravel. You are building a &lt;strong&gt;parallel ORM&lt;/strong&gt; that adds mapping code, cognitive load, and friction on every change.&lt;/p&gt;

&lt;p&gt;So the right question is not, "Should we replace Eloquent with typed objects?" The right question is, &lt;strong&gt;where does untyped Eloquent stop being cheap?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once you frame it that way, the migration path becomes much clearer. Keep Eloquent where it is good at persistence, hydration, scopes, relationships, and query composition. Introduce typed objects where the shape is messy, the values carry business meaning, or invalid combinations are too easy to represent.&lt;/p&gt;

&lt;p&gt;That is the version that pays off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Recommendation
&lt;/h2&gt;

&lt;p&gt;If you only remember one thing from this article, make it this: &lt;strong&gt;add typed boundaries around unstable or meaningful data, not around every model&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That usually means one of four cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a JSON column that multiple parts of the app interpret differently&lt;/li&gt;
&lt;li&gt;domain values like money, status, addresses, or billing configuration&lt;/li&gt;
&lt;li&gt;data crossing from Eloquent into services, jobs, or integrations&lt;/li&gt;
&lt;li&gt;code paths where stringly typed state has already caused confusion or bugs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else should be guilty until proven useful.&lt;/p&gt;

&lt;p&gt;This is where a lot of teams go wrong. They see a good example of typed objects and immediately generalize it into an architecture rule. Then every model gets a &lt;code&gt;FooData&lt;/code&gt;, &lt;code&gt;FooView&lt;/code&gt;, &lt;code&gt;FooState&lt;/code&gt;, &lt;code&gt;FooRecord&lt;/code&gt;, and &lt;code&gt;FooMapper&lt;/code&gt;. The app becomes more "designed" and less understandable.&lt;/p&gt;

&lt;p&gt;A Laravel codebase does not get better because it has more classes. It gets better because &lt;strong&gt;responsibility becomes clearer&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Raw Eloquent Starts Hurting
&lt;/h2&gt;

&lt;p&gt;Eloquent is deliberately permissive. That is one reason Laravel teams ship quickly with it. Attributes can be strings, arrays, JSON blobs, cast values, nullable timestamps, or whatever the database currently allows. Early on, that flexibility feels productive.&lt;/p&gt;

&lt;p&gt;The problems show up later, usually in boring places.&lt;/p&gt;

&lt;p&gt;A field that started as a simple JSON blob becomes important to billing or permissions. A string column that once held two status values now holds six, and one of them is only valid after a webhook arrives. A settings array is read by a controller, a queue job, an action class, and an API transformer, and each one assumes slightly different defaults.&lt;/p&gt;

&lt;p&gt;At that point, Eloquent is not the problem by itself. The problem is that &lt;strong&gt;storage shape and domain meaning are now fused together&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden cost of array-shaped logic
&lt;/h3&gt;

&lt;p&gt;This is the code smell you want to notice early:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'plan'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'trial_ends_at'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;settings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'cancel_at_period_end'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&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 line is doing at least three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reading a storage format&lt;/li&gt;
&lt;li&gt;applying defaults&lt;/li&gt;
&lt;li&gt;expressing business intent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is also impossible to scan quickly. The logic is not hard, but the shape is noisy. If five different parts of the system do their own version of that, you now have a maintenance problem.&lt;/p&gt;

&lt;p&gt;The deeper issue is not style. It is &lt;strong&gt;semantic drift&lt;/strong&gt;. One place defaults plan to &lt;code&gt;free&lt;/code&gt;. Another assumes missing plan is invalid. One path reads &lt;code&gt;trial_ends_at&lt;/code&gt; as nullable string. Another parses a Carbon instance. Eventually two code paths disagree silently.&lt;/p&gt;

&lt;p&gt;Typed boundaries help because they centralize interpretation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strings are cheap until they are not
&lt;/h3&gt;

&lt;p&gt;Laravel developers are used to raw strings for status fields, provider names, feature flags, event types, and mode switches. That is fine while the meaning is obvious and local.&lt;/p&gt;

&lt;p&gt;It stops being fine when the value crosses process boundaries or starts controlling workflow.&lt;/p&gt;

&lt;p&gt;A raw &lt;code&gt;status&lt;/code&gt; column with values like &lt;code&gt;draft&lt;/code&gt;, &lt;code&gt;published&lt;/code&gt;, &lt;code&gt;archived&lt;/code&gt;, and &lt;code&gt;scheduled&lt;/code&gt; does not look dangerous. But once those values drive API responses, jobs, admin actions, and permissions, the weakness becomes obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;typos are legal until runtime&lt;/li&gt;
&lt;li&gt;invalid transitions are hard to guard consistently&lt;/li&gt;
&lt;li&gt;IDE refactors cannot protect you&lt;/li&gt;
&lt;li&gt;business rules stay smeared across call sites&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typed object, enum, or small value object is not about code aesthetics here. It is about making the set of legal states more explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Good Typed Boundary Looks Like
&lt;/h2&gt;

&lt;p&gt;A good typed boundary does one or more of these things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normalizes messy incoming storage shape&lt;/li&gt;
&lt;li&gt;enforces a small invariant&lt;/li&gt;
&lt;li&gt;exposes behavior that belongs with the value&lt;/li&gt;
&lt;li&gt;gives the rest of the app a predictable interface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; exist just to mirror columns one-to-one.&lt;/p&gt;

&lt;p&gt;That distinction matters more than people admit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example 1: Typed settings around a JSON column
&lt;/h3&gt;

&lt;p&gt;A JSON column is one of the clearest places to introduce a typed object because the raw database shape tends to spread quickly.&lt;/p&gt;

&lt;p&gt;Imagine a &lt;code&gt;users.subscription_settings&lt;/code&gt; column. The naive version often starts as this:&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;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;subscription_settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'plan'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'cancel_at_period_end'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'trial_ends_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-06-30T00:00:00Z'&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 looks harmless. The trouble starts when those keys are read in ten places with ten slightly different assumptions.&lt;/p&gt;

&lt;p&gt;A better boundary is a typed object returned from a cast.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Domain\Billing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionSettings&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;string&lt;/span&gt; &lt;span class="nv"&gt;$plan&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;bool&lt;/span&gt; &lt;span class="nv"&gt;$cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nc"&gt;\DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$trialEndsAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;fromArray&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;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="s1"&gt;'plan'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;'cancel_at_period_end'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;trialEndsAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;isset&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="s1"&gt;'trial_ends_at'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'trial_ends_at'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="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;toArray&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'plan'&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="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'cancel_at_period_end'&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="n"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'trial_ends_at'&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="n"&gt;trialEndsAt&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;DATE_ATOM&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isOnTrial&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;trialEndsAt&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;trialEndsAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTimeImmutable&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;isEnterprise&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'enterprise'&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;Then wire it into Eloquent with a cast:&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Casts&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\Domain\Billing\SubscriptionSettings&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\Database\Eloquent\CastsAttributes&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\Database\Eloquent\Model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionSettingsCast&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CastsAttributes&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Model&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$value&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;$attributes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;SubscriptionSettings&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;JSON_THROW_ON_ERROR&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;SubscriptionSettings&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Model&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$value&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;$attributes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="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;$value&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionSettings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Expected SubscriptionSettings instance.'&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="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&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="no"&gt;JSON_THROW_ON_ERROR&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;And in the model:&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;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;casts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'subscription_settings'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;\App\Casts\SubscriptionSettingsCast&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="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;This is a strong pattern because it improves the code around the model without pretending Eloquent is gone. The storage remains JSON. The domain-facing interface becomes stable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this boundary is worth it
&lt;/h3&gt;

&lt;p&gt;This boundary is useful because it removes repeated interpretation from the rest of the app.&lt;/p&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every caller knows the raw keys&lt;/li&gt;
&lt;li&gt;every caller applies its own defaults&lt;/li&gt;
&lt;li&gt;every caller decides how to parse dates&lt;/li&gt;
&lt;li&gt;changing the payload shape is risky&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one place owns the mapping&lt;/li&gt;
&lt;li&gt;the type documents the meaning&lt;/li&gt;
&lt;li&gt;behavior lives with the data&lt;/li&gt;
&lt;li&gt;the rest of the app deals with a real object&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a real gain, not architecture theater.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Teams Accidentally Build a Parallel ORM
&lt;/h2&gt;

&lt;p&gt;The failure mode is predictable: a good local pattern gets promoted into a universal rule.&lt;/p&gt;

&lt;p&gt;Someone introduces typed objects for a messy JSON field. It works well. Then the team starts wrapping every model attribute in custom classes, every query result in DTOs, and every relationship traversal in mirrored object graphs.&lt;/p&gt;

&lt;p&gt;Now you have two representations of the same thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Eloquent model Laravel uses for persistence and relationships&lt;/li&gt;
&lt;li&gt;the typed object system your application uses for everything else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds disciplined. In practice, it often means every change hits five layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one-to-one wrapper trap
&lt;/h3&gt;

&lt;p&gt;This is the pattern to be suspicious of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostData&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;int&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="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&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;string&lt;/span&gt; &lt;span class="nv"&gt;$body&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;?string&lt;/span&gt; &lt;span class="nv"&gt;$publishedAt&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;If that class has no invariant, no normalization, no behavior, and no boundary value, it is probably dead weight.&lt;/p&gt;

&lt;p&gt;A one-to-one wrapper around database columns does not automatically create better design. It usually just moves the same ambiguity into another file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mapper explosion is a tax, not a virtue
&lt;/h3&gt;

&lt;p&gt;Once every model gets a wrapper, mappers multiply fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model to data object&lt;/li&gt;
&lt;li&gt;data object to API resource&lt;/li&gt;
&lt;li&gt;request payload to domain object&lt;/li&gt;
&lt;li&gt;domain object back to model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of that is necessary in large systems. Most of it is not necessary in a typical Laravel app.&lt;/p&gt;

&lt;p&gt;The discipline you want is &lt;strong&gt;selective conversion&lt;/strong&gt;, not universal conversion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Relationship mirroring is usually the breaking point
&lt;/h3&gt;

&lt;p&gt;The fastest way to overcomplicate this approach is trying to re-model Eloquent relationships as a separate typed graph.&lt;/p&gt;

&lt;p&gt;Laravel already gives you a lot here: eager loading, lazy loading, constraints, aggregate helpers, polymorphic relations, scopes, pivot behavior, and query composition. Rebuilding all of that behind a second object model is rarely worth it.&lt;/p&gt;

&lt;p&gt;If you end up with &lt;code&gt;TypedUser -&amp;gt; TypedTeam -&amp;gt; TypedSubscription -&amp;gt; TypedPlan&lt;/code&gt;, ask yourself whether the code got more explicit or just more indirect.&lt;/p&gt;

&lt;p&gt;Most of the time, typed boundaries should sit at &lt;strong&gt;meaningful seams&lt;/strong&gt;, not replace the entire navigation model of the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Better Way to Think About Boundaries in Laravel
&lt;/h2&gt;

&lt;p&gt;Instead of asking "which models should be typed?", ask which &lt;strong&gt;movements of data&lt;/strong&gt; deserve a stronger contract.&lt;/p&gt;

&lt;p&gt;That usually leads to better decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Boundary 1: Storage to domain meaning
&lt;/h3&gt;

&lt;p&gt;This is the cast example. A raw database representation becomes a typed value with a stable API.&lt;/p&gt;

&lt;p&gt;Good fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON columns
n- compound values stored across loose fields&lt;/li&gt;
&lt;li&gt;normalized status or settings logic&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Boundary 2: Domain to external integration
&lt;/h3&gt;

&lt;p&gt;When Eloquent data is sent to Stripe, OpenAI, Slack, or an internal service, raw model state often leaks too much incidental shape.&lt;/p&gt;

&lt;p&gt;A typed object or dedicated payload object is useful here because it decouples your internal persistence model from the integration contract.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoicePayload&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;string&lt;/span&gt; &lt;span class="nv"&gt;$customerEmail&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;int&lt;/span&gt; &lt;span class="nv"&gt;$amountInCents&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;string&lt;/span&gt; &lt;span class="nv"&gt;$currency&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;string&lt;/span&gt; &lt;span class="nv"&gt;$description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;fromOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;customerEmail&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;customer_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;amountInCents&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Order #&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$order&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That boundary matters because integrations are expensive places to be sloppy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Boundary 3: Eloquent to async work
&lt;/h3&gt;

&lt;p&gt;Jobs, events, and queued actions are another strong seam.&lt;/p&gt;

&lt;p&gt;Passing full models through queues can be fine, but it can also create subtle coupling to later database state. Sometimes the job should see whatever the model looks like when it runs. Sometimes it should operate on a precise snapshot.&lt;/p&gt;

&lt;p&gt;A typed payload object is often safer when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the job must preserve exact values from dispatch time&lt;/li&gt;
&lt;li&gt;only a subset of model data is relevant&lt;/li&gt;
&lt;li&gt;you want the job contract to stay stable as the model evolves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a rule against queueing models. It is a reminder that &lt;strong&gt;async boundaries magnify ambiguity&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental Adoption That Does Not Blow Up the Codebase
&lt;/h2&gt;

&lt;p&gt;The right migration path is boring on purpose. That is a good sign.&lt;/p&gt;

&lt;p&gt;Do not start with a grand refactor. Start with a recurring pain point and tighten just that boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Find repeated interpretation
&lt;/h3&gt;

&lt;p&gt;Look for code patterns like these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;repeated &lt;code&gt;['some_key'] ?? default&lt;/code&gt; access&lt;/li&gt;
&lt;li&gt;status strings checked in multiple services&lt;/li&gt;
&lt;li&gt;date parsing scattered across call sites&lt;/li&gt;
&lt;li&gt;the same normalization logic repeated in requests, jobs, and resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is your signal that the raw shape has escaped too far.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Introduce one typed object
&lt;/h3&gt;

&lt;p&gt;Pick one value with clear meaning. Do not start with the most central model in the app. Start with something painful but contained.&lt;/p&gt;

&lt;p&gt;Good first targets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;shipping or billing address&lt;/li&gt;
&lt;li&gt;subscription settings&lt;/li&gt;
&lt;li&gt;money totals&lt;/li&gt;
&lt;li&gt;workflow state object&lt;/li&gt;
&lt;li&gt;external API payloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first win should be easy to explain: &lt;strong&gt;we used to interpret this shape everywhere; now we interpret it once&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Put behavior with the value
&lt;/h3&gt;

&lt;p&gt;A typed boundary that only carries properties is better than raw arrays, but the larger payoff comes when it exposes behavior.&lt;/p&gt;

&lt;p&gt;Bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shipping_address&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'IN'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shipping_address&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;postalCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shipping_address&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isDomesticFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'IN'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;shipping_address&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasPostalCode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not about hiding all detail. It is about letting the type speak in domain language.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Stop when the boundary is doing its job
&lt;/h3&gt;

&lt;p&gt;This is where restraint matters.&lt;/p&gt;

&lt;p&gt;Once a typed object solves the ambiguity, do not automatically expand the pattern outward. You do not need a typed object for every neighboring value just because one boundary worked well.&lt;/p&gt;

&lt;p&gt;Architecture gets bloated when people confuse a good pattern with a universal pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 2: A Shipping Address Object That Actually Earns Its Keep
&lt;/h2&gt;

&lt;p&gt;A shipping address is a good example because it often starts simple and becomes annoying over time.&lt;/p&gt;

&lt;p&gt;You begin with a JSON blob or a handful of nullable fields. Then checkout logic, tax calculations, delivery rules, admin review, and label generation all want slightly different things from it.&lt;/p&gt;

&lt;p&gt;A typed object helps because address data has both &lt;strong&gt;shape&lt;/strong&gt; and &lt;strong&gt;behavior&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Domain\Orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ShippingAddress&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;string&lt;/span&gt; &lt;span class="nv"&gt;$line1&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;?string&lt;/span&gt; &lt;span class="nv"&gt;$line2&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;string&lt;/span&gt; &lt;span class="nv"&gt;$city&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;string&lt;/span&gt; &lt;span class="nv"&gt;$state&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;string&lt;/span&gt; &lt;span class="nv"&gt;$postalCode&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;string&lt;/span&gt; &lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;fromArray&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;self&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;line1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;'line_1'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="n"&gt;line2&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;isset&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="s1"&gt;'line_2'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&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="s1"&gt;'line_2'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;city&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;'state'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="n"&gt;postalCode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;'postal_code'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'postcode'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;strtoupper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="s1"&gt;'country'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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;toArray&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'line_1'&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="n"&gt;line1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'line_2'&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="n"&gt;line2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'city'&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="n"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'state'&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="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'postal_code'&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="n"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'country'&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="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isDomesticFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$countryCode&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strtoupper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$countryCode&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;hasPostalCode&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;postalCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;formattedSingleLine&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;collect&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;line1&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;line2&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;city&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;state&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;postalCode&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;country&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;filter&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;implode&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="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;This object is doing real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normalizing &lt;code&gt;postcode&lt;/code&gt; vs &lt;code&gt;postal_code&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;normalizing country casing&lt;/li&gt;
&lt;li&gt;centralizing formatting&lt;/li&gt;
&lt;li&gt;exposing behavior useful to rules and integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is what "typed object around Eloquent" should usually mean in a Laravel app. Not total abstraction. Just a sharper seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure Modes to Watch For
&lt;/h2&gt;

&lt;p&gt;Most architectural patterns do not fail because the first example is bad. They fail because teams stop being selective.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode 1: treating all data as domain data
&lt;/h3&gt;

&lt;p&gt;Some data is just persistence detail. Audit columns, import metadata, view counters, internal sort positions, and similar fields often do not need domain objects.&lt;/p&gt;

&lt;p&gt;If there is no meaningful behavior or invariant, raw Eloquent may be the correct level of abstraction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode 2: mixing internal types with API shape
&lt;/h3&gt;

&lt;p&gt;Your internal object and your API resource are not the same thing by default.&lt;/p&gt;

&lt;p&gt;A domain object should express meaning. An API response should express what clients need. Sometimes those line up. Often they do not.&lt;/p&gt;

&lt;p&gt;When you merge them too early, the domain object ends up carrying serialization quirks, presentation formatting, and backwards compatibility baggage.&lt;/p&gt;

&lt;p&gt;Keep that split clean unless you have a strong reason not to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode 3: assuming stronger types remove validation needs
&lt;/h3&gt;

&lt;p&gt;Typed objects do not replace validation. They complement it.&lt;/p&gt;

&lt;p&gt;Requests still need input validation. Database constraints still matter. Integration boundaries still need defensive checks.&lt;/p&gt;

&lt;p&gt;A typed object improves how your application represents a value after it enters the system. It does not magically guarantee the outside world behaved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure mode 4: hiding too much behind tiny methods
&lt;/h3&gt;

&lt;p&gt;There is also an opposite failure mode: turning every property read into a method call just to sound domain-driven.&lt;/p&gt;

&lt;p&gt;If a type becomes a wall of trivial wrappers, readability suffers again.&lt;/p&gt;

&lt;p&gt;The rule is simple: extract behavior when the behavior is meaningful, repeated, or protection-worthy. Do not hide obvious data behind noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Strategy That Matches This Pattern
&lt;/h2&gt;

&lt;p&gt;One benefit of typed boundaries is that they make testing narrower and more honest.&lt;/p&gt;

&lt;p&gt;You do not need to boot a full Laravel feature test to verify every bit of interpretation logic.&lt;/p&gt;

&lt;p&gt;Test the object directly for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normalization rules&lt;/li&gt;
&lt;li&gt;derived behavior&lt;/li&gt;
&lt;li&gt;invalid state rejection if applicable&lt;/li&gt;
&lt;li&gt;serialization back to storage shape&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then add a smaller number of model-level tests to verify the cast integration actually works.&lt;/p&gt;

&lt;p&gt;That split is useful because it keeps your business semantics testable without pushing everything through Eloquent every time.&lt;/p&gt;

&lt;p&gt;The trap to avoid is writing fragile tests that just reassert the implementation line by line. Test the boundary contract, not the existence of getters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Decision Rule
&lt;/h2&gt;

&lt;p&gt;Use typed Eloquent objects when they remove ambiguity, centralize interpretation, or protect meaningful business rules.&lt;/p&gt;

&lt;p&gt;Do not use them as a blanket ideology.&lt;/p&gt;

&lt;p&gt;If a raw model attribute is read in one place, has obvious meaning, and carries no important invariant, leave it alone. If a value is messy, reused, or expensive to misunderstand, give it a proper type.&lt;/p&gt;

&lt;p&gt;That is the sweet spot.&lt;/p&gt;

&lt;p&gt;Laravel does not need to be purified away from Eloquent. It needs cleaner seams between &lt;strong&gt;storage concerns&lt;/strong&gt; and &lt;strong&gt;application meaning&lt;/strong&gt;. Typed boundaries are excellent at that when used carefully. They are expensive when used everywhere.&lt;/p&gt;

&lt;p&gt;So if you are introducing this pattern into an existing app, start small. Pick one ugly boundary. Add one typed object. Move one cluster of logic into it. Watch whether the surrounding code gets simpler.&lt;/p&gt;

&lt;p&gt;If it does, keep going where the same pain exists.&lt;/p&gt;

&lt;p&gt;If it does not, stop before your migration story turns into an accidental rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal is not to make the codebase feel more abstract. The goal is to make invalid states harder, business logic clearer, and Eloquent less noisy where it actually matters.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/typed-eloquent-boundaries-without-rewriting-your-laravel-app/" rel="noopener noreferrer"&gt;https://qcode.in/typed-eloquent-boundaries-without-rewriting-your-laravel-app/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>eloquent</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Drag-and-drop ordering in Laravel admin tools gets messy faster than it looks</title>
      <dc:creator>Saqueib Ansari</dc:creator>
      <pubDate>Fri, 05 Jun 2026 12:03:55 +0000</pubDate>
      <link>https://dev.to/saqueib/drag-and-drop-ordering-in-laravel-admin-tools-gets-messy-faster-than-it-looks-4gha</link>
      <guid>https://dev.to/saqueib/drag-and-drop-ordering-in-laravel-admin-tools-gets-messy-faster-than-it-looks-4gha</guid>
      <description>&lt;p&gt;Most admin drag-and-drop ordering features are sold as a UI improvement. In practice, they are usually a data-model decision disguised as polish.&lt;/p&gt;

&lt;p&gt;I learned this the hard way. The first version always feels cheap: add a drag handle, send an array of IDs, update a &lt;code&gt;position&lt;/code&gt; column, done. Everyone feels productive because the interaction is visible and satisfying. Then the real questions arrive. What exactly is being ordered? What happens when two admins reorder at once? Is the order global or scoped? Does page 2 still mean anything after a reorder? Can support explain who changed it and why? Can a keyboard user do the same job without fighting the interface?&lt;/p&gt;

&lt;p&gt;That is the hidden cost. &lt;strong&gt;Drag-and-drop ordering is not hard because reindexing integers is hard. It is hard because the feature forces your product to define truth about sequence, scope, and intent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My rule now is simple: if the order is not a first-class business concept, do not make the list draggable. In Laravel admin tools especially, explicit ranking, pinning, or scoped “move” actions usually age better than freeform sorting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first mistake is usually conceptual, not technical
&lt;/h2&gt;

&lt;p&gt;Most teams start by asking how to implement sortable rows. That is already too late. The real first question is: &lt;strong&gt;what exact collection owns this order?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is vague, the database is about to start lying.&lt;/p&gt;

&lt;p&gt;Take a typical admin screen for articles, users, tickets, or products. An operator sees a table, maybe filtered by status or search, and drags one row above another. The UI implies they are reordering the list they can see. But what is that list, exactly?&lt;/p&gt;

&lt;p&gt;Is it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;all records in the table&lt;/li&gt;
&lt;li&gt;records within one tenant&lt;/li&gt;
&lt;li&gt;records within one category&lt;/li&gt;
&lt;li&gt;records matching the current filter&lt;/li&gt;
&lt;li&gt;records on the current page only&lt;/li&gt;
&lt;li&gt;records inside a hand-curated editorial collection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are completely different contracts. Most “quick” drag-and-drop implementations store one global &lt;code&gt;position&lt;/code&gt; and defer the hard part. That works right up until the interface shows a partial slice of the data and the user assumes the slice is the truth.&lt;/p&gt;

&lt;p&gt;That is the failure mode I now look for first. A record that appears first in a filtered list may be position &lt;code&gt;42&lt;/code&gt; in the actual stored sequence. If the user drags it lower in that filtered view, they think they changed local order. The system may actually rewrite a much broader global order they never meant to touch.&lt;/p&gt;

&lt;p&gt;This is why I do not treat order as a presentation concern anymore. It is domain state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Order only behaves well when the scope is explicit
&lt;/h3&gt;

&lt;p&gt;There are cases where manual order is absolutely legitimate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;homepage hero cards&lt;/li&gt;
&lt;li&gt;navigation menus&lt;/li&gt;
&lt;li&gt;onboarding steps&lt;/li&gt;
&lt;li&gt;playlist items&lt;/li&gt;
&lt;li&gt;kanban cards within a column&lt;/li&gt;
&lt;li&gt;custom fields within a form builder&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each of those cases, the ordered set has a clear parent. The order means something to the business. Users understand that meaning. The list is usually small enough to reason about as a whole.&lt;/p&gt;

&lt;p&gt;That is the shape you want.&lt;/p&gt;

&lt;p&gt;A good rule is that a reorderable record should be able to answer this sentence cleanly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I am item X at position Y within collection Z.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your system cannot fill in Z precisely, you probably do not have a reorderable domain. You have a sortable UI illusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laravel makes the happy path dangerously cheap
&lt;/h2&gt;

&lt;p&gt;Laravel is excellent at getting CRUD features over the line. That is normally a strength. With drag-and-drop ordering, it can hide the real cost.&lt;/p&gt;

&lt;p&gt;The easy version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/admin/articles/reorder'&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;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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ids'&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="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nc"&gt;Article&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereKey&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'position'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="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;noContent&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 code is short, readable, and wrong for most non-trivial admin systems.&lt;/p&gt;

&lt;p&gt;It assumes all of the following without stating any of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the client sent a complete authoritative list&lt;/li&gt;
&lt;li&gt;the list belongs to one stable scope&lt;/li&gt;
&lt;li&gt;no one else changed that scope concurrently&lt;/li&gt;
&lt;li&gt;the current page is the whole sequence that matters&lt;/li&gt;
&lt;li&gt;overwriting every position is acceptable&lt;/li&gt;
&lt;li&gt;auditability does not matter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is too much unstated product logic for one loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  A safer baseline starts in the schema
&lt;/h3&gt;

&lt;p&gt;If order matters, define it as scoped order in the database itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;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;'playlist_items'&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;'playlist_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;foreignId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'track_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;unsignedInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'position'&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;unsignedInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order_version'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$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="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'playlist_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'position'&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;unique&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'playlist_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'track_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;index&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'playlist_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'position'&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 schema says something valuable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;position&lt;/code&gt; is not globally meaningful&lt;/li&gt;
&lt;li&gt;collisions are prevented within the parent scope&lt;/li&gt;
&lt;li&gt;reads have a stable index&lt;/li&gt;
&lt;li&gt;concurrency can be reasoned about via &lt;code&gt;order_version&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That already eliminates a surprising amount of ambiguity.&lt;/p&gt;

&lt;h3&gt;
  
  
  The write path should validate the scope it mutates
&lt;/h3&gt;

&lt;p&gt;When I do accept full-list reorder requests, I want the server to prove that the payload actually matches the current scoped list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReorderPlaylistItems&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;Playlist&lt;/span&gt; &lt;span class="nv"&gt;$playlist&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;$orderedIds&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;$actor&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="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;$playlist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orderedIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$playlist&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;items&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;select&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="s1"&gt;'position'&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;lockForUpdate&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;'position'&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="nv"&gt;$expectedIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$items&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;values&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;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$incomingIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orderedIds&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;$incomingIds&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$expectedIds&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;array_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expectedIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$incomingIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                    &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Reorder payload does not match the current playlist scope.'&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$incomingIds&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nv"&gt;$playlist&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;items&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;whereKey&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                    &lt;span class="s1"&gt;'position'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$playlist&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'order_version'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nf"&gt;activity&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;performedOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$playlist&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;causedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$actor&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;withProperties&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                    &lt;span class="s1"&gt;'before'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$items&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;$item&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;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$item&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;'position'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;position&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;all&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="s1"&gt;'after'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$incomingIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="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;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$index&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;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'position'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'playlist_reordered'&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;Even here, notice how much work sits around the actual reindexing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;row locking&lt;/li&gt;
&lt;li&gt;payload validation&lt;/li&gt;
&lt;li&gt;scope validation&lt;/li&gt;
&lt;li&gt;version bumping&lt;/li&gt;
&lt;li&gt;auditing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the hidden cost in code form. The reorder logic is the easy part. Everything around it is the feature.&lt;/p&gt;

&lt;p&gt;Laravel’s database and pagination docs are relevant here because they make it very easy to work with ordered and partial result sets, but they do not remove the product-level decisions: &lt;a href="https://laravel.com/docs/12.x/database" rel="noopener noreferrer"&gt;https://laravel.com/docs/12.x/database&lt;/a&gt; and &lt;a href="https://laravel.com/docs/12.x/pagination" rel="noopener noreferrer"&gt;https://laravel.com/docs/12.x/pagination&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency is where “simple sorting” stops being simple
&lt;/h2&gt;

&lt;p&gt;A reorderable list is fine in a single-user demo. Admin systems are rarely single-user systems.&lt;/p&gt;

&lt;p&gt;The first time two operators touch the same collection, your assumptions get tested. One person moves item A to the top. Another moves item C below item D. Both actions are reasonable. Both can produce valid writes. One of them is still going to feel like the application ignored their intent.&lt;/p&gt;

&lt;p&gt;That means the feature needs a concurrency story, not just a controller action.&lt;/p&gt;

&lt;h3&gt;
  
  
  Last-write-wins is simple and usually bad
&lt;/h3&gt;

&lt;p&gt;The default implicit behavior in many apps is last-write-wins. Whoever submits second overwrites the first order silently.&lt;/p&gt;

&lt;p&gt;That is easy to implement and terrible for operator trust. It creates three support problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;users think the interface is glitchy&lt;/li&gt;
&lt;li&gt;admins cannot explain why order changed unexpectedly&lt;/li&gt;
&lt;li&gt;auditing shows a valid write but not the lost intent behind it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For non-trivial admin workflows, silent overwrite is not a neutral choice. It is product debt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Revision-based rejection is boring and correct
&lt;/h3&gt;

&lt;p&gt;The most honest pattern I have used is optimistic concurrency with an order version.&lt;/p&gt;

&lt;p&gt;The client receives the current &lt;code&gt;order_version&lt;/code&gt; with the list. The reorder request sends it back. If the stored version changed, the server rejects with &lt;code&gt;409 Conflict&lt;/code&gt; and the UI reloads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReorderPlaylistRequest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;FormRequest&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;rules&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'ordered_ids'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'ordered_ids.*'&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;'integer'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'version'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReorderPlaylistController&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;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;ReorderPlaylistRequest&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;Playlist&lt;/span&gt; &lt;span class="nv"&gt;$playlist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;ReorderPlaylistItems&lt;/span&gt; &lt;span class="nv"&gt;$service&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_if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'version'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$playlist&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'This list changed. Reload and try again.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$service&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$playlist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ordered_ids'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;'ok'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'version'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$playlist&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;order_version&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;This is not clever, and that is why it works. It acknowledges that two people cannot meaningfully reorder the same list at the same time without conflict.&lt;/p&gt;

&lt;h3&gt;
  
  
  Relative move commands often scale better than full-list rewrites
&lt;/h3&gt;

&lt;p&gt;For larger collections, I increasingly prefer explicit commands like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;move item 48 before item 31&lt;/li&gt;
&lt;li&gt;move item 12 after item 17&lt;/li&gt;
&lt;li&gt;move item 7 to top&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why? Because they match user intent better and reduce the blast radius of a change.&lt;/p&gt;

&lt;p&gt;A full-list payload says, “the browser knows the canonical entire order.” That is rarely true once pagination, filtering, or lazy loading exist. A relative move command says, “within this scoped collection, perform this concrete adjustment.” That is a much cleaner contract.&lt;/p&gt;

&lt;p&gt;It also makes future implementation options easier. You can keep dense integer positions for small lists, or switch later to gap-based ranking, fractional ordering, or periodic normalization without changing the UI semantics too much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pagination is usually the point where the feature becomes dishonest
&lt;/h2&gt;

&lt;p&gt;I have a strong opinion here: &lt;strong&gt;if a list needs pagination, drag-and-drop is probably the wrong default ordering interaction.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not always, but usually.&lt;/p&gt;

&lt;p&gt;The reason is not technical difficulty alone. It is user expectation.&lt;/p&gt;

&lt;p&gt;When someone drags rows around, they assume they are manipulating a visible whole. Pagination tells them the opposite: this is only a slice. Those two mental models fight each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  The real questions pagination creates
&lt;/h3&gt;

&lt;p&gt;Suppose an admin table shows 25 rows per page.&lt;/p&gt;

&lt;p&gt;If a user drags row 25 to the top of page 1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;should row 26 move to page 1 now&lt;/li&gt;
&lt;li&gt;should page 2 reshuffle live&lt;/li&gt;
&lt;li&gt;is the user editing global order or page-local order&lt;/li&gt;
&lt;li&gt;what happens if the sorted set is filtered by search&lt;/li&gt;
&lt;li&gt;what does “move to bottom” even mean without loading the whole sequence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these questions are cosmetic. They determine whether the feature is trustworthy.&lt;/p&gt;

&lt;p&gt;The common workaround is to allow dragging only within the current page. That sounds pragmatic, but it often creates a worse lie. The UI looks like global ordering, but the behavior is actually page-local mutation against a hidden global sequence.&lt;/p&gt;

&lt;p&gt;This is exactly the kind of feature that feels okay in a staging demo and then confuses operators for months.&lt;/p&gt;

&lt;h3&gt;
  
  
  Better patterns for large admin collections
&lt;/h3&gt;

&lt;p&gt;If the collection is too large to comfortably view as a whole, I would usually choose one of these instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;move up&lt;/code&gt; and &lt;code&gt;move down&lt;/code&gt; controls for fine adjustment&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pin to top&lt;/code&gt; and &lt;code&gt;unpin&lt;/code&gt; for featured content&lt;/li&gt;
&lt;li&gt;buckets like &lt;code&gt;featured&lt;/code&gt;, &lt;code&gt;standard&lt;/code&gt;, &lt;code&gt;archived&lt;/code&gt;, &lt;code&gt;hidden&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;explicit numeric rank inputs for users who truly manage sequence&lt;/li&gt;
&lt;li&gt;weighted ordering where &lt;code&gt;manual_rank&lt;/code&gt; is only one signal in a stable composite sort&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those patterns do not feel as magical as drag-and-drop. They are also easier to explain, easier to audit, and much less likely to make the database represent fake precision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exports make the mismatch worse
&lt;/h3&gt;

&lt;p&gt;The moment exported CSVs, API feeds, or downstream jobs depend on the same ordered dataset, your reorder feature is no longer just a UI convenience.&lt;/p&gt;

&lt;p&gt;If order affects exports, the questions become sharper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;is export order global or filtered&lt;/li&gt;
&lt;li&gt;does a transient admin view change customer-facing order&lt;/li&gt;
&lt;li&gt;can two successive exports differ because an operator dragged a row mid-run&lt;/li&gt;
&lt;li&gt;do downstream consumers rely on that order as business priority&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where I see teams accidentally turning &lt;code&gt;position&lt;/code&gt; into policy. A hand-adjusted admin rank becomes an invisible source of truth for systems that were never meant to depend on it.&lt;/p&gt;

&lt;p&gt;If that is really the business requirement, fine. But then treat it with that seriousness. If it is not, do not let a sortable grid define more truth than the product team intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditability and accessibility are not edge cases
&lt;/h2&gt;

&lt;p&gt;When teams say drag-and-drop is “working,” they usually mean the rows move and persist. In admin tooling, that is not enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  If order matters, the change must be explainable later
&lt;/h3&gt;

&lt;p&gt;Someone will eventually ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who changed the order&lt;/li&gt;
&lt;li&gt;when it changed&lt;/li&gt;
&lt;li&gt;what the previous order was&lt;/li&gt;
&lt;li&gt;whether the change was deliberate&lt;/li&gt;
&lt;li&gt;whether the operator only meant to change one subset&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A plain &lt;code&gt;position&lt;/code&gt; column cannot answer any of that. If order influences what staff or customers see, log reorder events as events, not just row diffs. Store actor, scope, before-state when affordable, after-state, and request context.&lt;/p&gt;

&lt;p&gt;This is also why I prefer order changes that are explicit in intent. “Moved Pricing card above FAQ in Homepage section” is a meaningful audit event. “Updated 47 position values” is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keyboard access changes the product design in a good way
&lt;/h3&gt;

&lt;p&gt;Most drag-and-drop interfaces are pointer-first and accessibility-second. That is a product smell.&lt;/p&gt;

&lt;p&gt;If a reorderable task matters, it needs a complete non-pointer path. In practice that usually means controls like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;move to top&lt;/li&gt;
&lt;li&gt;move up&lt;/li&gt;
&lt;li&gt;move down&lt;/li&gt;
&lt;li&gt;move to bottom&lt;/li&gt;
&lt;li&gt;move before selected item&lt;/li&gt;
&lt;li&gt;move after selected item&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Teams sometimes treat this as a compliance add-on. I think that is backwards. When you design those commands well, the whole feature gets better. Intent becomes explicit. Precision improves. Support gets clearer language. Audits become easier to understand.&lt;/p&gt;

&lt;p&gt;That is one of the strongest signals that freeform dragging was doing too much theatrical work and not enough operational work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ship instead in most Laravel admin tools
&lt;/h2&gt;

&lt;p&gt;My default production pattern now is not “sortable rows.” It is &lt;strong&gt;scoped rank with explicit commands&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The shape is usually:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;define a parent scope clearly&lt;/li&gt;
&lt;li&gt;store a nullable &lt;code&gt;manual_rank&lt;/code&gt; or scoped &lt;code&gt;position&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;combine it with a stable secondary sort like &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, or business priority&lt;/li&gt;
&lt;li&gt;expose deliberate actions instead of unconstrained dragging&lt;/li&gt;
&lt;li&gt;audit every reorder operation that affects shared admin state&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example, a practical query often looks like this:&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;$articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Article&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$tenant&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderByRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CASE WHEN manual_rank IS NULL THEN 1 ELSE 0 END'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manual_rank'&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;orderByDesc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'published_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;paginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That model is much more honest. Ranked items float where the business explicitly placed them. Unranked items still behave predictably. Pagination remains understandable. You can add “pin,” “move up,” or direct rank edits without pretending every record lives in one sacred total order.&lt;/p&gt;

&lt;h3&gt;
  
  
  When I still allow drag-and-drop
&lt;/h3&gt;

&lt;p&gt;I still use it in a narrow band of cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the list is small&lt;/li&gt;
&lt;li&gt;the whole list is visible&lt;/li&gt;
&lt;li&gt;the scope is obvious&lt;/li&gt;
&lt;li&gt;the order matters as a business artifact&lt;/li&gt;
&lt;li&gt;concurrency conflicts are acceptable or explicitly handled&lt;/li&gt;
&lt;li&gt;auditability exists&lt;/li&gt;
&lt;li&gt;keyboard alternatives exist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That usually means editorial and builder-style interfaces, not broad CRUD tables.&lt;/p&gt;

&lt;p&gt;If your feature fails two or three of those tests, do not compensate with more JavaScript and stronger opinions about the frontend library. The problem is probably the contract, not the drag handle.&lt;/p&gt;

&lt;p&gt;The practical takeaway is blunt because it needs to be. &lt;strong&gt;Do not add drag-and-drop ordering just because it looks intuitive. Add it only when the ordered collection is real, bounded, auditable, and small enough to reason about as a whole.&lt;/strong&gt; In every other case, explicit ranking beats theatrical sorting, and your database will tell fewer lies.&lt;/p&gt;




&lt;p&gt;Read the full post on QCode: &lt;a href="https://qcode.in/the-hidden-cost-of-adding-drag-and-drop-ordering-to-admin-tools/" rel="noopener noreferrer"&gt;https://qcode.in/the-hidden-cost-of-adding-drag-and-drop-ordering-to-admin-tools/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>database</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
  </channel>
</rss>
