N+1 Query Problem
debt(d7/e3/b5/t7)
Closest to 'only careful code review or runtime testing' (d7), slightly better. Tools like Laravel Debugbar and Clockwork (listed in detection_hints.tools) can surface N+1 at runtime by showing 50+ identical queries, and phpstan with plugins can flag it statically — but these require deliberate installation and configuration, and the problem is completely invisible without a query debugger as noted in common_mistakes. It won't surface in normal test runs unless query counting is explicitly asserted.
Closest to 'simple parameterised fix' (e3). The quick_fix is adding with() or equivalent eager loading to the outer query — a small, targeted change. However, common_mistakes note it can spread to controllers, API resources, and view composers, meaning the same pattern may need fixing in several places. This pushes it slightly above e1 but stays at e3 because each individual fix is a small local change rather than a cross-cutting refactor.
Closest to 'persistent productivity tax' (b5). The problem applies across web, cli, and queue-worker contexts, meaning it can silently degrade any feature that iterates over a collection with relationships. Without a query debugger permanently installed, every new feature touching relational data risks reintroducing it. It doesn't restructure the entire codebase (not b7) but it does impose an ongoing vigilance tax across many work streams.
Closest to 'serious trap' (t7). The misconception field explicitly states developers believe N+1 only affects ORMs, when in reality any loop-with-query pattern causes it. This contradicts intuition: developers who carefully write manual SQL can still introduce it, and adding with() to the outer query but missing nested relationships (with('orders') vs with('orders.items')) is a subtle secondary trap. The 'obvious' fix (just add with()) is incomplete without understanding relationship depth.
Also Known As
TL;DR
Explanation
The N+1 problem occurs when code fetches a list of entities and then executes an additional query for each one — typically to load a related record. For 100 rows this means 101 queries; for 1000 rows, 1001. Database round-trip overhead makes this disproportionately slow compared to a single JOIN or a single IN() query. The fix is to pre-load all related data in one query and look it up from a keyed array in memory.
Diagram
sequenceDiagram
participant APP as Application
participant DB as Database
APP->>DB: SELECT * FROM posts - 1 query
DB-->>APP: 100 posts returned
loop For each of 100 posts
APP->>DB: SELECT * FROM users WHERE id = ?
DB-->>APP: 1 user
end
Note over APP,DB: 1 + 100 = 101 queries total
Note over APP,DB: Fix: eager load with JOIN<br/>SELECT * FROM users WHERE id IN (1,2,3...)
Common Misconception
Why It Matters
Common Mistakes
- Accessing a relationship inside a loop without eager loading — ORM lazy-loads on every iteration.
- Adding with() to the outer query but forgetting nested relationships (with('orders.items') instead of with('orders')).
- Fixing N+1 in controllers but leaving it in API resources or view composers that loop over collections.
- Not installing a query debugger (Laravel Debugbar, DBAL logger) — N+1 is invisible without query counting.
Code Examples
// N+1: 1 query for posts + N queries for author names
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // triggers a new query per post
}
$posts = Post::with('author')->get(); // 2 queries total
foreach ($posts as $post) {
echo $post->author->name;
}