The N+1 Query Problem: Kill It Before It Kills Your App
Tags: Laravel · PHP · Performance
Reading time: ~8 min
After a decade building Laravel applications — from startup MVPs to systems processing millions of records daily — I can tell you with confidence: the N+1 query problem is one of the most underestimated performance bugs in the PHP ecosystem. It doesn’t throw an exception. It doesn’t make your tests fail. It silently degrades your application until one day a client calls you asking why the dashboard takes 12 seconds to load.
Table of Contents
- What exactly is the N+1 problem?
- How it manifests in a real application
- How to identify it in your codebase
- Solving it with Eager Loading
- Preventing it at the framework level
- Going deeper: real-world tips & edge cases
- Conclusion
What exactly is the N+1 problem?
The N+1 problem is a database anti-pattern that occurs when your application executes one initial query to fetch a collection of records, followed by one additional query for each record to retrieve related data. The name comes directly from the math: if you fetch N records, you fire N additional queries — totalling N+1 round-trips to the database.
⚠️ Core Definition
N+1 occurs when related data is fetched lazily inside a loop — one extra query per parent record — instead of being loaded upfront in a single batch query. It is an ORM anti-pattern, not a PHP or Laravel-specific bug.
What makes it deceptive is that it works perfectly in development. With 10 records in your local database, the extra queries are invisible. Deploy to production with 50,000 customers and the same code causes catastrophic degradation. The application that felt snappy in staging becomes a timeout nightmare in production.
How it manifests in a real application
Let’s use a classic e-commerce scenario. We have two tables: customers and orders. A customer has many orders — a standard one-to-many relationship. We need to build a dashboard that lists all customers alongside each of their order descriptions.
The naive implementation — and the one that almost every junior developer will write — looks like this:
Query 1 — runs once
SELECT id, name, email FROM customers;
Query 2 — runs once PER customer (500×):
SELECT description FROM orders WHERE customer_id = ?;
With 500 customers, that’s 501 queries hitting the database. With 5,000 customers? 5,001 queries. The number scales linearly with your data — and so does your response time.
| Approach | Queries (500 customers) | Queries (5,000 customers) |
|---|---|---|
| ❌ N+1 (Lazy Loading) | 501 | 5,001 |
| ✅ Eager Loading | 2 | 2 |
| 📉 Query reduction | — | 99.6% |
Here is the exact Laravel controller code that triggers this problem. Everything looks clean — no obvious red flags. That’s what makes it dangerous:
<?php
namespace AppHttpControllers;
use AppModelsCustomer;
class TestController extends Controller
{
public function index()
{
// Query 1: fetch all customers
$customers = Customer::all();
// Triggers 1 query PER customer inside the loop!
$customers = $customers->filter(function ($model) {
return $model->order->count() > 0; // ← N queries here
});
return response(['result' => $customers]);
}
}
The culprit is the $model->order access inside the filter() callback. Laravel’s Eloquent ORM uses lazy loading by default — meaning relationships are only fetched from the database when you first access them. Since $model->order is accessed once per iteration, it fires one query per customer.
⚡ Performance Rule of Thumb
If you see a database query being called inside any type of loop —foreach,filter(),map(),each()— you almost certainly have an N+1 problem. The loop itself isn’t the issue; the relationship access inside it is.
How to identify it in your codebase
Spotting N+1 problems early saves you hours of debugging in production. Here are the tools and techniques I rely on:
Laravel Telescope
Laravel Telescope is the single best tool for catching N+1 queries during development. Navigate to /telescope/queries and look for duplicate queries — Telescope groups them and shows you exactly how many times an identical query pattern was executed. If you see “499 of 501 queries are duplicated”, that’s your smoking gun.
Laravel Debugbar
The barryvdh/laravel-debugbar package attaches a toolbar to every HTML response showing total query count, individual query times, and duplicate detection. If a page is firing more than a handful of queries, it appears immediately in the toolbar without any extra steps.
Code patterns to watch for
A — Relationship access inside a loop.
Any call like $item->relation, $item->relation->count(), or $item->relation->first() within foreach, map(), filter(), or Blade @foreach templates.
B — Missing with() on Eloquent queries.
Whenever you fetch a collection that you know will access relationships, ask: is ::with('relation') present?
C — Blade templates.
N+1 bugs often hide in Blade — a clean controller can mask dirty view logic. @foreach ($posts as $post) {{ $post->author->name }} @endforeach is N+1 if author wasn’t eager loaded.
Solving it with Eager Loading
The primary solution is Eager Loading — telling Eloquent to load all related models upfront in a second, batched query using a WHERE IN clause, rather than lazily fetching them one by one inside a loop.
Instead of firing 500 individual queries:
-- ❌ 500 separate queries
SELECT description FROM orders WHERE customer_id = 1;
SELECT description FROM orders WHERE customer_id = 2;
SELECT description FROM orders WHERE customer_id = 3;
-- ... 497 more identical queries
Eager loading collapses all of them into a single batched query:
-- ✅ 1 query, regardless of how many customers you have
SELECT description FROM orders
WHERE customer_id IN (1, 2, 3, 4, 5, ..., 500);
The fix in Laravel is a single method call — with():
<?php
namespace AppHttpControllers;
use AppModelsCustomer;
class TestController extends Controller
{
public function index()
{
// with('order') loads all orders in 1 extra batched query
$customers = Customer::with('order')->get();
// Now $model->order is already in memory — no extra queries!
$customers = $customers->filter(function ($model) {
return $model->order->count() > 0;
});
return response(['result' => $customers]);
}
}
✅ The Result
Total queries drop from 501 to 2: oneSELECT * FROM customersand oneSELECT * FROM orders WHERE customer_id IN (...). Response time for 500 records drops from ~48ms to ~4ms in typical setups — a 12× improvement, and it only gets better at larger scales.
Eager loading multiple and nested relationships
In real applications, you often need to eager load several relationships at once — or even deeply nested ones. Laravel handles this elegantly:
// Load multiple relationships at once
Customer::with(['orders', 'address', 'invoices'])->get();
// Load nested relationships (orders AND each order's product)
Customer::with('orders.product')->get();
// Constrain the eager loaded relationship
Customer::with(['orders' => function ($query) {
$query->where('status', 'paid')
->orderBy('created_at', 'desc');
}])->get();
// Lazy eager loading — add eager loading after collection is fetched
$customers = Customer::all();
$customers->load('orders'); // still just 1 extra query
// Eager load counts without loading the actual records
Customer::withCount('orders')->get(); // adds orders_count to each model
💡 Pro Tip
UsewithCount()when you only need the number of related records, not the records themselves. It’s more efficient than loading all related models just to call->count()on them, and keeps the response payload lighter in API scenarios.
Preventing it at the framework level
Fixing existing N+1 problems is important. Preventing new ones from being introduced is even better. Laravel provides two model-level configurations for this.
1. preventLazyLoading() — Laravel ≥ 8.43
This method configures Eloquent to throw an exception whenever a relationship is accessed lazily — i.e., without having been eager loaded first. It turns N+1 bugs into loud, visible errors during development, so they never survive a code review.
<?php
namespace AppProviders;
use IlluminateDatabaseEloquentModel;
use IlluminateSupportServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Enabled in non-production environments only
// Throws LazyLoadingViolationException if lazy loading is attempted
Model::preventLazyLoading(! $this->app->isProduction());
}
}
The condition !$this->app->isProduction() is intentional and important. You enable strict lazy loading enforcement in development and staging, so developers catch violations immediately. In production, you disable it — because a thrown exception there would crash your application for users, which is worse than the original N+1 problem.
When triggered, Laravel throws an IlluminateDatabaseLazyLoadingViolationException:
IlluminateDatabaseLazyLoadingViolationException
Attempted to lazy load [order] on model [AppModelsCustomer] but lazy loading is disabled.
2. automaticallyEagerLoadRelationships() — Laravel ≥ 12.9
This newer method takes a different, more pragmatic approach. Rather than throwing exceptions, it automatically applies eager loading to any query that touches related models. It detects relationship access patterns and batches the queries on your behalf — even if you forgot to write ::with().
<?php
namespace AppProviders;
use IlluminateDatabaseEloquentModel;
use IlluminateSupportServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
// Automatically eager loads any accessed relationships
// Works transparently — no code changes needed
Model::automaticallyEagerLoadRelationships();
}
}
ℹ️ Which one should you use?
UsepreventLazyLoading()in new projects as a development discipline tool — it forces developers to be explicit about their data needs, which leads to better, more intentional code. UseautomaticallyEagerLoadRelationships()in legacy codebases where you can’t audit every query but need an immediate performance improvement without rewriting everything.
Going deeper: real-world tips & edge cases
After years in the trenches, here are a few patterns that have saved me and my teams countless hours:
Global scopes and eager loading
If your model has a global scope that joins another table, make sure it doesn’t introduce implicit N+1 patterns. Always test with Telescope when adding global scopes to frequently-queried models.
Polymorphic relationships need extra care
Eager loading polymorphic relationships — morphTo, morphMany — can behave differently from standard hasMany. When using $model->commentable (a morphTo), use with('commentable') but also consider morphWith() for more granular control over which related types get loaded.
API Resources: the hidden N+1 vector
Laravel API Resources are a common place where N+1 bugs hide. A UserResource that accesses $this->orders inside toArray() will trigger lazy loading if the controller didn’t eager load orders. Always use the Resource’s whenLoaded() method to conditionally include relationships only when they’ve been eager loaded:
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// ✅ Only included if 'orders' was eager loaded by the controller
'orders' => OrderResource::collection($this->whenLoaded('orders')),
];
}
Don’t forget Blade templates
N+1 bugs in Blade templates are extremely common and often overlooked during code review because they’re hidden in .blade.php files rather than controllers. A simple audit: search your views for ->relation patterns inside @foreach blocks and verify the parent query has eager loaded them.
Conclusion
The N+1 problem is not exotic or complex — it’s a straightforward consequence of how ORMs handle lazy loading. But its impact on production systems can be devastating: response times inflated by orders of magnitude, unnecessary database load, and user experience that degrades as your data grows.
The solution requires almost no effort: a single ::with() call can turn 501 queries into 2. The prevention tools Laravel provides — preventLazyLoading() and automaticallyEagerLoadRelationships() — mean there’s no excuse for letting these bugs reach production anymore.
My recommendation: enable preventLazyLoading(!$this->app->isProduction()) in every new Laravel project from day one. Let it be strict during development. Force every developer on your team to think about their data access patterns explicitly. Combine it with Telescope for visibility, and withCount() when you need aggregates.
Performance is a feature. Your users feel every wasted query. Your infrastructure pays for every unnecessary round-trip. The N+1 problem is entirely avoidable — and now you have everything you need to eliminate it for good.
Written by a Senior Laravel / PHP Full Stack Developer with 10+ years of experience building scalable applications.



Publicar comentário