The report page was quick all through development. It went live, and three months later it takes nine seconds and somebody has put a loading spinner on it.
Nobody changed the code. What changed is that the table now has two thousand rows instead of twenty, and the page was never doing one slow thing. It was doing one fast thing two thousand times.
That is an N+1: one query to get a list, then one more query per item in it. It is the single most common performance bug in database-backed applications, and it is almost invisible until the data is real.

What it looks like
The plain PHP version is obvious once you see it and completely ordinary while you are writing it:
$orders = $db->query('SELECT * FROM orders WHERE status = "open"')->fetchAll();
foreach ($orders as $order) {
$stmt = $db->prepare('SELECT name FROM customers WHERE id = ?');
$stmt->execute([$order['customer_id']]);
$order['customer'] = $stmt->fetch(); // one query, every loop
}
Two hundred open orders is two hundred and one queries. Each one takes perhaps two milliseconds, which is fine, and two hundred of them is four hundred milliseconds of pure round trips before anything is rendered.
The ORM version is worse because nothing in it looks like a query at all:
@foreach ($orders as $order)
<td>{{ $order->customer->name }}</td>
@endforeach
That reads as a property access. It is a database call, issued lazily the first time each $order is asked for its customer, and there is nothing on the line to suggest it. This is why N+1 is so much more common with an ORM: the thing that makes the ORM pleasant is the thing that hides the cost.
Why it survives review
Three reasons, and they compound.
It is fast in development. Your local database has twenty rows and no network between the app and MySQL. Twenty-one queries on localhost is a few milliseconds. The bug is invisible at the only scale you test at.
It degrades linearly, so it never breaks. It gets slower every month by an amount nobody notices week to week, until somebody finally says the page feels bad and by then it has been broken for a year.
And nothing in the code is wrong. Every individual line is correct, indexed, and doing the smallest sensible thing. Code review catches bad queries; it rarely catches good queries in a loop.
Find it by counting, not by reading
Reading code to spot N+1 works until the loop and the lazy access are in different files, which they usually are. Counting is reliable.
In Laravel, the shortest useful version is to log the count per request:
// AppServiceProvider::boot(), local and staging only
if (app()->environment('local', 'staging')) {
DB::listen(function ($q) {
Log::debug(sprintf('%.1f ms %s', $q->time, $q->sql));
});
app()->terminating(function () {
$n = count(DB::getQueryLog());
if ($n > 25) {
Log::warning("{$n} queries on " . request()->path());
}
});
}
In plain PHP, wrap PDO and count. Either way, what you are looking for is not slow queries — it is the same query repeated with a different parameter.
Better still, make it fail rather than log. Laravel has Model::preventLazyLoading(), which throws the moment a relationship loads lazily. Turn it on in local and staging and leave it off in production, and an N+1 becomes something you cannot merge rather than something you find in six months.

The fix, and the fix that is not one
Load the related rows in one query instead of N. In Laravel that is with():
$orders = Order::with('customer')->where('status', 'open')->get();
Two queries instead of two hundred and one: one for the orders, one WHERE id IN (...) for the customers, stitched together in memory.
In plain PHP it is the same idea, written out:
$orders = $db->query('SELECT * FROM orders WHERE status = "open"')->fetchAll();
$ids = array_unique(array_column($orders, 'customer_id'));
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $db->prepare("SELECT id, name FROM customers WHERE id IN ($in)");
$st->execute($ids);
$customers = $st->fetchAll(PDO::FETCH_KEY_PAIR);
foreach ($orders as &$order) {
$order['customer'] = $customers[$order['customer_id']] ?? null;
}
Now the part people skip. Eager loading everything is its own bug.
A model with protected $with = ['customer', 'items', 'invoices'] loads all three on every query whether the page uses them or not. You have replaced two hundred small queries with four large ones that pull megabytes nobody reads. That is often slower, and it is harder to notice because the query count looks healthy.
Load what the page uses. If one page needs items and another does not, that is two different calls, not one default.
The four that hide
A query counter catches the obvious case. These four get past it.

Counting in a loop. $post->comments->count() inside a foreach either loads every comment row to count them, or issues a COUNT per post. withCount('comments') gets all of them in the original query as one extra column.
Nested relationships. with('orders') fixes the orders and leaves $order->items lazy inside the inner loop. That is an N+1 hiding behind a fix. The dot syntax goes all the way down: with('orders.items').
Accessors that query. A getFullAddressAttribute() that reaches for $this->country->name issues a query every time the attribute is read, and the call site looks like a string. These are the hardest to find because the loop and the query are nowhere near each other.
Queries inside a Blade partial or a view helper. The controller is clean, the template includes a partial per row, and the partial does a lookup. Nobody reviewing the controller will ever see it.
Two queries or one join?
Eager loading issues a second query. A join issues one. The instinct is that one must be better, and it often is not.
A join returns the parent columns repeated once per child row. Two hundred orders with an average of eight items each is sixteen hundred rows, and every one of them carries the full order record — the address, the notes, the totals — all duplicated. The database did less work; the wire and the PHP process did considerably more.
Eager loading sends two hundred order rows and however many distinct customers, each exactly once, and matches them up in memory. For a one-to-many relationship that is almost always the smaller payload.
Where the join genuinely wins is when you need to filter or sort by the related table. “Open orders from customers in Tamil Nadu, newest first” cannot be answered by loading orders and then loading customers, because the database has to see both to filter. Eager loading with a constraint still fetches every order first and throws most away.
// Filtering by the related table: the join earns its place
$orders = Order::join('customers', 'customers.id', '=', 'orders.customer_id')
->where('customers.state', 'Tamil Nadu')
->where('orders.status', 'open')
->select('orders.*', 'customers.name AS customer_name')
->orderByDesc('orders.created_at')
->get();
The working rule: join to filter, eager load to display. If the related table appears in a WHERE or an ORDER BY, join. If it only appears in the output, load it separately.
And in Laravel there is a middle option worth knowing: whereHas('customer', ...) filters with a subquery while keeping the display side eager loaded, which is usually the right shape when you need both.
When N+1 is the right answer
Worth saying, because the rule gets applied past its usefulness.
If N is genuinely small and bounded — a page that shows five items, always — two extra queries are cheaper than the work of avoiding them, and a joined query returning duplicated parent rows can move more data than the loop did.
If the related data is heavily cached, the N calls may not touch the database at all. Replacing them with a join can make things slower by bypassing a cache that was working.
And there is a real limit at the other end: WHERE id IN (...) with fifty thousand ids is its own problem. Above a few thousand, chunk it.
The rule that holds is narrower than “never query in a loop”: never query in a loop whose length is set by the data. A loop over a fixed list of five statuses is fine. A loop over whatever rows came back is not.
Put a number on the page
The reason N+1 keeps happening is that query count is invisible. Nobody is careless; the information simply is not in front of them.
Make it visible. Laravel Debugbar shows the count and flags duplicates in the corner of every page. Without it, a middleware that appends the count to an X-Query-Count response header takes ten minutes and works everywhere, including on API endpoints where there is no page to look at.
Then pick a number your pages should stay under — twenty-five is a reasonable starting line — and treat crossing it as something to look at rather than something to accept.
The one thing to take away
An N+1 does not announce itself. There is no error, no slow query in the log, no line of code that looks wrong. There is only a page that was fine last year.
So do not rely on noticing. Count queries per request in development, make lazy loading throw in staging, and the whole class of bug stops reaching production — which is a much better arrangement than being good at spotting them.



