{"id":539,"date":"2026-08-03T05:03:58","date_gmt":"2026-08-03T05:03:58","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/n-plus-1-query-problem-how-i-catch-it-in-laravel\/"},"modified":"2026-08-03T05:03:58","modified_gmt":"2026-08-03T05:03:58","slug":"n-plus-1-query-problem-how-i-catch-it-in-laravel","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/n-plus-1-query-problem-how-i-catch-it-in-laravel\/","title":{"rendered":"The N+1 Query Problem: How I Actually Catch It in Laravel"},"content":{"rendered":"<p>Short version for the impatient: the N+1 query problem is almost never a mystery, it&rsquo;s just invisible until you count. Add a query counter to one test and you&rsquo;ll find it in an afternoon.<\/p>\n<p>Now the longer version, which involves me looking slightly stupid.<\/p>\n<p>A while back I had an orders dashboard that felt fine locally and took about four seconds in production. I assumed it was the database being slow, so I spent an evening adding indexes to a table that already had every index it needed. Then I finally turned on query logging and watched 1,903 queries scroll past for a single page load. Fifty orders, each one fetching its customer, each one counting its line items. The database wasn&rsquo;t slow. I was asking it the same question four hundred times and being surprised it took a while to answer.<\/p>\n<p>That&rsquo;s N+1. One query to get a list, then N more queries because you touched a relationship inside a loop. Every ORM has a version of this. Eloquent&rsquo;s version is friendlier than most, which is exactly why it&rsquo;s so easy to ship.<\/p>\n<h2 id=\"the-shape-of-the-bug-i-keep-writing\">The shape of the bug I keep writing<\/h2>\n<p>Here&rsquo;s the controller. Nothing wrong with it at a glance:<\/p>\n<pre><code class=\"language-php\">public function index()\n{\n    $orders = Order::latest()-&gt;paginate(50);\n\n    return view('orders.index', compact('orders'));\n}\n<\/code><\/pre>\n<p>And here&rsquo;s the Blade template that quietly ruins it:<\/p>\n<pre><code class=\"language-blade\">@foreach ($orders as $order)\n    &lt;tr&gt;\n        &lt;td&gt;{{ $order-&gt;reference }}&lt;\/td&gt;\n        &lt;td&gt;{{ $order-&gt;customer-&gt;name }}&lt;\/td&gt;\n        &lt;td&gt;{{ $order-&gt;items-&gt;count() }} items&lt;\/td&gt;\n    &lt;\/tr&gt;\n@endforeach\n<\/code><\/pre>\n<p>One query for the page of orders. Then fifty for <code>$order-&gt;customer<\/code>. Then fifty more for <code>$order-&gt;items<\/code>. That&rsquo;s 101 round trips to render a table.<\/p>\n<p>What makes this hard to spot is that it&rsquo;s split across two files. The controller looks clean. The template looks clean. The bug lives in the gap between them, and it doesn&rsquo;t exist until someone writes a <code>@foreach<\/code>. I&rsquo;ve reviewed pull requests that introduced this and approved them, because the diff was three lines of Blade and none of those lines said &ldquo;run fifty queries&rdquo;.<\/p>\n<p>The fix is the boring one everyone already knows:<\/p>\n<pre><code class=\"language-php\">$orders = Order::query()\n    -&gt;with('customer:id,name')\n    -&gt;withCount('items')\n    -&gt;latest()\n    -&gt;paginate(50);\n<\/code><\/pre>\n<p>Three queries now. One for the orders, one for the customers, one for the item counts. In the template, <code>$order-&gt;items-&gt;count()<\/code> becomes <code>$order-&gt;items_count<\/code>, which is a column on the result rather than a fresh query. Laravel&rsquo;s <a href=\"https:\/\/laravel.com\/docs\/12.x\/eloquent-relationships\" rel=\"nofollow noopener\" target=\"_blank\">eager loading docs<\/a> cover the syntax properly if you want the full list of what <code>with()<\/code> accepts.<\/p>\n<h2 id=\"eager-loading-fixes-the-query-count-not-necessarily-the-page\">Eager loading fixes the query count, not necessarily the page<\/h2>\n<p>This is the part I got wrong for a good two weeks, and it cost me a lot of confused staring.<\/p>\n<p>The first time I fixed an N+1 by adding <code>with()<\/code>, the query count dropped from 1,903 to 4 and the page got <em>slower<\/em>. Not much slower. But it didn&rsquo;t get faster, which was the entire point of the exercise.<\/p>\n<p>The reason: I&rsquo;d eager loaded a relationship with a <code>text<\/code> column in it. Something like <code>with('customer')<\/code> on a customers table that had a <code>notes<\/code> column holding a few kilobytes of free text per row. Fifty small queries became one query that dragged half a megabyte across the wire and then hydrated fifty full Eloquent models out of it. Fewer queries, more bytes, more object construction.<\/p>\n<p>So two habits I picked up:<\/p>\n<p>Select only what you need. <code>with('customer:id,name')<\/code> instead of <code>with('customer')<\/code>. The <code>id<\/code> is mandatory, by the way, because Eloquent needs it to match children back to parents. Leave it out and you get an empty relation and a very confusing ten minutes.<\/p>\n<p>Use <code>withCount()<\/code> when you only want a number. Loading four thousand line items to call <code>count()<\/code> on them in PHP is a waste of memory that the database will happily do for you in one pass.<\/p>\n<p>The general principle is that query count is a proxy metric. It&rsquo;s a good proxy, and I still use it as my first signal, but the thing you actually care about is time and memory. Sometimes those move together. Sometimes they don&rsquo;t. I hit a similar version of this when I was benchmarking search, which I wrote up in <a href=\"https:\/\/abrarqasim.com\/blog\/postgres-full-text-search-2026-when-i-reach-for-it-over-elasticsearch\" rel=\"noopener\">my post on Postgres full-text search<\/a>. The fastest option on paper lost once real row sizes showed up.<\/p>\n<h2 id=\"the-one-setting-that-stops-this-at-the-source\">The one setting that stops this at the source<\/h2>\n<p>If you take one thing from this post, take this. Laravel can throw an exception the moment you lazy load a relationship, so the bug fails loudly in development instead of quietly in production.<\/p>\n<p>In <code>AppServiceProvider<\/code>:<\/p>\n<pre><code class=\"language-php\">use Illuminate\\Database\\Eloquent\\Model;\n\npublic function boot(): void\n{\n    Model::preventLazyLoading(! $this-&gt;app-&gt;isProduction());\n}\n<\/code><\/pre>\n<p>That&rsquo;s it. Touch <code>$order-&gt;customer<\/code> without eager loading it and you get a <code>LazyLoadingViolationException<\/code> with the model and relation named in the message. The <a href=\"https:\/\/laravel.com\/docs\/12.x\/eloquent\" rel=\"nofollow noopener\" target=\"_blank\">Eloquent strictness docs<\/a> explain the related toggles too, including <code>preventSilentlyDiscardingAttributes<\/code>, which has caught its own share of my mistakes.<\/p>\n<p>The <code>! $this-&gt;app-&gt;isProduction()<\/code> bit matters. You want the exception in local and CI. You do not want your checkout page throwing 500s at 2am because someone missed a <code>with()<\/code> in a code path nobody tested. Fail loud where it&rsquo;s cheap, degrade quietly where it isn&rsquo;t.<\/p>\n<p>I resisted this setting for longer than I should have, because the first time I switched it on it broke about nine pages at once and I assumed the setting was too aggressive. It wasn&rsquo;t. Those nine pages were all doing N+1. It was just an honest bill arriving all at once.<\/p>\n<p>Two things to know before you flip it. Turning it on in an existing app is a day of work, not a five minute change, so budget for that. And if you have a genuinely intentional lazy load, <code>Model::preventLazyLoading(false)<\/code> inside a narrow scope beats disabling the whole thing.<\/p>\n<h2 id=\"catching-it-in-ci-not-in-code-review\">Catching it in CI, not in code review<\/h2>\n<p>Humans are bad at counting queries by reading diffs. I&rsquo;ve proven this repeatedly. So I let the test suite do it.<\/p>\n<p>The trick is that <code>DB::listen<\/code> gives you a hook on every query, and a counter around a request is about six lines:<\/p>\n<pre><code class=\"language-php\">use Illuminate\\Support\\Facades\\DB;\n\ntest('orders index stays under a query budget', function () {\n    Order::factory()-&gt;count(30)-&gt;hasItems(4)-&gt;create();\n\n    $queries = 0;\n    DB::listen(function () use (&amp;$queries) {\n        $queries++;\n    });\n\n    $this-&gt;get('\/orders')-&gt;assertOk();\n\n    expect($queries)-&gt;toBeLessThan(10);\n});\n<\/code><\/pre>\n<p>The number 10 is arbitrary and that&rsquo;s fine. The point isn&rsquo;t precision, it&rsquo;s the direction of the alarm. If someone adds a relationship access to that Blade template, this test goes from 4 queries to 34 and fails. Nobody has to notice anything during review.<\/p>\n<p>I put one of these on every page that renders a list. It&rsquo;s maybe fifteen minutes of work per route and it has caught more real regressions than any linter I&rsquo;ve installed.<\/p>\n<p>For local development there&rsquo;s also <a href=\"https:\/\/github.com\/beyondcode\/laravel-query-detector\" rel=\"nofollow noopener\" target=\"_blank\">beyondcode\/laravel-query-detector<\/a>, which watches requests as you browse and warns you when a relationship gets hit repeatedly. It can output to the log, the browser console, or Debugbar. I keep it on log output, because browser alerts during development make me want to throw my laptop into the sea.<\/p>\n<h2 id=\"when-eager-loading-is-the-wrong-answer-entirely\">When eager loading is the wrong answer entirely<\/h2>\n<p>Not every repeated query wants <code>with()<\/code>.<\/p>\n<p>Large exports are the obvious one. If you&rsquo;re iterating 200,000 rows, eager loading the relation means holding all of it in memory at once, and your job dies on a box with 512MB. <code>lazyById()<\/code> keeps memory flat while still chunking sensibly:<\/p>\n<pre><code class=\"language-php\">Order::with('items')-&gt;lazyById(500)-&gt;each(function (Order $order) {\n    \/\/ one chunk of 500 in memory at a time\n});\n<\/code><\/pre>\n<p>Aggregates are the other one I see people get wrong. If the answer is a single number, don&rsquo;t hydrate models to get it. This:<\/p>\n<pre><code class=\"language-php\">$total = Order::with('items')-&gt;get()\n    -&gt;sum(fn ($order) =&gt; $order-&gt;items-&gt;sum('price'));\n<\/code><\/pre>\n<p>is a great way to load your entire orders table into PHP so you can add up a column. The database will do it in one pass without constructing a single object.<\/p>\n<p>And sometimes the honest answer is that a page is asking for too much, and it needs a dedicated read query or a summary table rather than a cleverer ORM call. I&rsquo;ve stopped treating &ldquo;write raw SQL for this one endpoint&rdquo; as a defeat.<\/p>\n<p>Background jobs deserve a mention here too, since moving heavy work off the request is often the real fix. I wrote about how I run that side of things in <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-horizon-in-production-the-queue-setup-i-stopped-babysitting\" rel=\"noopener\">my Horizon setup post<\/a>.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Pick your slowest list page. Add <code>Model::preventLazyLoading(! $this-&gt;app-&gt;isProduction())<\/code> to <code>AppServiceProvider<\/code>, load the page locally, and see what explodes. Fix whatever the exception names, then write one query-budget test around that route so it stays fixed.<\/p>\n<p>That&rsquo;s a couple of hours, most of it mechanical, and in my experience it finds more wall-clock time than any index you were about to add. Most of the Laravel work I do these days starts with exactly this kind of unglamorous measurement pass, and you can see some of what comes out of it in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my project work<\/a>.<\/p>\n<p>The database was never the problem. The loop was.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Eloquent&#8217;s N+1 problem hid a four-second page load from me for weeks. Here is how I catch it now: strict lazy loading plus a query budget test in CI.<\/p>\n","protected":false},"author":2,"featured_media":538,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Eloquent's N+1 problem hid a four-second page load from me for weeks. Here is how I catch it now: strict lazy loading plus a query budget test in CI.","rank_math_focus_keyword":"n+1 query problem","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,52],"tags":[49,606,607,260,56,605,53],"class_list":["post-539","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-php","tag-backend","tag-database-performance","tag-eager-loading","tag-eloquent","tag-laravel","tag-n-1-query","tag-php"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/539","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=539"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/539\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/538"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=539"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=539"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=539"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}