Lesson 117·M15 Eloquent & the Database
Eager Loading & the N+1 Problem
OpenLearning Objectives
By the end of this lesson you should be able to:
- Recognise the N+1 pattern on sight and count the queries it produces
- Explain why N+1 is O(N) queries and why that grows with data
- Fix it with
with('posts')and say exactly what SQL that runs - Use
load()/loadMissing()to eager-load after the fact - Eager-load nested and conditional relations (
with(['posts' => fn ($q) => …])) - Detect N+1 with
Model::preventLazyLoading(),once(), and the query log
What is the N+1 Problem?
Loading N rows, then loading one related thing per row — N extra queries. If you fetch 100
users and touch $user->posts for each, that's 1 query for users + 100 for posts = 101
queries. The "N+1" is the N relation queries plus the original one.
$users = User::all(); // query #1
foreach ($users as $user) {
echo $user->posts->count(); // query #2, #3, #4, … one PER user
}
It's the single most common performance bug in Laravel apps, precisely because the code reads perfectly — nothing in the syntax hints that a query just ran.
Mental Model
Think of the relationship property as a vending machine: every single access costs a
quarter (a query). Inside a loop, that's N quarters. Eager loading is buying all of them in
one transaction — one query, WHERE user_id IN (all the ids), handed out row by row.
| Approach | Queries for 100 users | SQL executed |
|---|---|---|
| Lazy (the bug) | 1 + 100 | 1× SELECT users, 100× SELECT posts WHERE user_id = ? |
Eager (with('posts')) | 1 + 1 | 1× SELECT users, 1× SELECT posts WHERE user_id IN (…) |
The ratio is the story: lazy = N+1, eager = 2, no matter how many rows.
Visual Flow
LAZY — 100 users → 101 queries
┌─────────────────────────────────────────────┐
│ SELECT * FROM users │ ← 1 query
│ foreach $user: │
│ SELECT * FROM posts WHERE user_id = 1 │ ┐
│ SELECT * FROM posts WHERE user_id = 2 │ │
│ SELECT * FROM posts WHERE user_id = 3 │ ├─ 100 queries
│ … │ │
│ SELECT * FROM posts WHERE user_id = 100 │ ┘
└─────────────────────────────────────────────┘
EAGER — 100 users → 2 queries
┌─────────────────────────────────────────────┐
│ SELECT * FROM users │ ← 1 query
│ SELECT * FROM posts │ ← 1 query
│ WHERE user_id IN (1, 2, 3, …, 100) │
│ │
│ Eloquent then groups the posts by user_id │
│ and attaches each set to its user, in PHP. │
└─────────────────────────────────────────────┘
Eager loading moves the grouping from the database round-trips into a single in-memory
pass — one query with a big IN, then a groupBy in PHP.
How it Works: Why Lazy Loading Runs N Queries
When you access $user->posts, Eloquent runs the relationship query for that one model,
then caches the result on the instance. A loop over 100 models means 100 separate WHERE user_id = ? executions, each with its own network round-trip:
$users = User::all(); // 1 query
foreach ($users as $user) {
$user->posts; // 100 queries — one per user
}
The actual SQL traffic:
SELECT * FROM users;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
...
SELECT * FROM posts WHERE user_id = 100;
TOTAL: 101 queries
Every SELECT * FROM posts WHERE user_id = ? is a full round-trip to the database. At 1–2ms
each you're at 100–200ms of pure query overhead before a single row is rendered — and the
count grows linearly with data. 10,000 users on a report page = 10,001 queries and a timeout.
Pitfall
It gets worse with nested relations. $user->posts + $post->comments inside the same
loop is N+1 queries per user — 100 users × (posts + comments) explodes to O(N×M). Eager
loading scales each level down to one query, not each relationship occurrence.
How it Works: The Fix — with() and the IN Query
with() tells Eloquent to fetch the relationship for all loaded models up front, using a
single query with a WHERE … IN (…):
$users = User::with('posts')->get(); // 2 queries, not 101
foreach ($users as $user) {
echo $user->posts->count(); // already loaded — no SQL
}
What Eloquent actually runs:
SELECT * FROM users;
SELECT * FROM posts
WHERE user_id IN (1, 2, 3, ..., 100);
TOTAL: 2 queries
Afterwards Eloquent collects every returned post, groups them by user_id in memory, and
writes each group onto its matching User instance. Your code is byte-for-byte identical —
$user->posts works exactly the same — only the query count changed.
Tip
The relationship method ($user->posts()) returns the builder — safe to chain, never
fires by itself. The property ($user->posts) is what triggers the query. That distinction
is why with('posts') and $user->posts()->count() can both avoid N+1 without you seeing
the difference in the code.
Real Project Usage
N+1 almost never shows up as a headline bug — it hides in list pages, dashboards and exports:
// A dashboard "latest activity" feed — the classic place N+1 hides.
$users = User::with('posts')
->withCount('posts')
->orderBy('last_login_at', 'desc')
->take(20)
->get();
// withCount avoids a SECOND N+1: post counts become a subquery, not 20 more queries.
foreach ($users as $user) {
echo "{$user->name} — {$user->posts_count} posts\n";
}
Real project rules of thumb:
- Index pages, dashboards, exports —
with()the relations you render. withCountwhen you only need a number per row (one subquery, not N).- Forms and detail pages usually load one row — no loop, no N+1.
Interview Explanation
The N+1 problem is loading N parent rows and then triggering one query per row by accessing a lazy relationship —
User::all()followed by$user->postsin a loop is 1 + N queries. The fix is eager loading:User::with('posts')->get()runs the posts query once, with aWHERE user_id IN (…), and Eloquent attaches the results to the right models in memory — so 100 users costs 2 queries, not 101.The deeper point is that lazy relationship access hides a query per access, so the way to stop shipping N+1 is to make it visible:
Model::preventLazyLoading()in development throws the moment an unloaded relation is accessed, and the query log or Debugbar shows the count in black and white.
Senior-Level Insights
- Say the growth, not just the bug. "1 + N queries, so linear in N, which means it's invisible at 10 rows and lethal at 10,000" — naming the scaling is what sounds senior.
preventLazyLoading()belongs in development, not production. Throwing an exception on a hot production path is a great way to take the site down. Enable it in local/testing, and ship the fix instead.- Nested
with()is where the real queries live.with(['author.profile'])is two queries (author, then profile),with(['posts.comments', 'tags'])is three — count per level, and the total stays flat no matter how many rows. - Eager loading is a contract, not a tweak. The moment you render a list, decide which
relations it needs and
with()them — otherwise the "fix" is a whack-a-mole game where each new dev adds one more lazy access. load()andloadMissing()matter for branching code. When only some rows need a relation (an admin flag, a first-item check), load it conditionally — always-eager loading pays for relations you rarely use.
Common Mistakes
- ❌
with('posts')on the first query, then a nested lazy access anyway —$user->postsis loaded, but$post->commentsisn't. The fix must cover every level you touch. - ❌ Eager-loading relations you never use —
with(['posts', 'comments', 'likes'])on a page that renders one column is 3 queries you didn't need. - ❌
preventLazyLoading()in production — the correct move is dev-only, so CI catches N+1 before deploy, not after. - ❌ Forgetting
withCount— counting posts in a loop is the same N+1, just withCOUNT(*). - ❌ Caching eager-loaded models — serialising models with loaded relations and reading them back later re-triggers the lazy load on access (or carries stale data).
- ❌ Looping
find()in a loop —User::find($id)per iteration is a manual N+1; onewhereIn('id', $ids)fixes it.
Best Practices
✅ with() every relationship you render — count levels, not rows
✅ Use withCount('posts') instead of $user->posts->count() in loops
✅ Nest deliberately: with(['posts.comments']) when you'll traverse both levels
✅ Load conditionally with load() / loadMissing() for branching code paths
✅ Run Model::preventLazyLoading() in development and tests so N+1 throws
✅ Keep an eye on the query count in Debugbar / the query log before every deploy
Interview Questions
Q1. What is the N+1 problem in Laravel?
Loading N parent rows, then accessing a lazy relationship once per row —
User::all()with$user->postsin a loop issues 1 query for users plus N separate queries for posts. The total grows linearly with the result size, so it's harmless in tests and destructive in production.
Q2. Why is it inefficient?
Every relationship access is a full database round-trip with its own query plan and network latency. 100 users means 100
WHERE user_id = ?executions instead of one. The cost is O(N) round-trips on top of the data itself — and nested relations make it O(N×M).
Q3. How do you fix it?
Eager loading:
User::with('posts')->get(). Eloquent fetches all posts in one query withWHERE user_id IN (…), groups them in memory, and attaches them to the right users. Same code afterwards — 100 users now cost 2 queries total.
Q4. What is the difference between with() and load()?
with()loads relations as part of the original query.load()eager-loads after the models already exist — when you didn't know until runtime which relations you'd need. Both run the same two-query pattern; they differ in timing.
Q5. What is loadMissing()?
Like
load(), but it only loads relations that aren't already loaded. If a collection was built in pieces and some models already carry the relation,loadMissing()avoids the duplicate query.
Q6. How do you detect N+1 in your app?
Turn on
Model::preventLazyLoading()in development — the first access to an unloaded relation throws. For a single request,Model::preventLazyLoading(true, true)(theonceflag). Or watch the query count: Laravel Debugbar shows it per request, andDB::getQueryLog()/DB::listen()print every query.
Q7. How do you eager-load a relation with a condition?
With a closure:
User::with(['posts' => fn ($query) => $query->where('published', true)]) ->get(). The closure receives the relation's query builder, so it only constrains the eager-loaded set — the users query is untouched.
Q8. How many queries does User::with(['posts.comments'])->get() run?
Three, regardless of row count: users, then posts for all users (
IN), then comments for all those posts (IN). One per level of the graph, not one per row.
Senior follow-up: A report page runs 1000 queries. Walk me through how you'd fix it.
First, measure — I'd pull the query log or Debugbar and confirm the shape: is it one query plus N per parent, or nested N×M? Then map the touched relations and
with()each level I actually render, usingwithCountfor totals andloadMissingwhere the path is conditional. I'd also runModel::preventLazyLoading()locally to make any stragglers throw. After the fix I'd re-run the log and verify the count collapsed to one query per level — then wirepreventLazyLoadinginto the test suite so it can't regress.
Follow-Up Questions
Q1. What's the difference between eager loading and lazy eager loading?
Eager loading (
with()) runs at the initial query. Lazy eager loading (load()/loadMissing()) runs later, on a collection you already have — same SQL shape, decided at a different moment. Lazy eager loading is for when you can't know the needs up front.
Q2. Does eager loading use a JOIN?
No — and that's the part people get wrong.
with('posts')is two separate queries: the parents, then the children withWHERE user_id IN (…). Eloquent joins them in PHP. A real JOIN (join()orwith()of a constrained relation) merges rows instead, which is right for filtering but wrong for loading a relationship.
Q3. Why does $user->posts() not cause N+1 but $user->posts does?
posts()returns the query builder — it never executes.postsaccesses the relationship and runs it. Calling$user->posts()and chaining->count()or->where()gives you a single query without loading everything. The "lazy load" only happens on property access.
Q4. When is a single query with a JOIN actually better than eager loading?
When you're filtering by the related table — "users who have written a published post" is one query with a JOIN or
whereHas/exists; eager loading gives you all users then all posts. Filtering is a query-shape problem; loading is a round-trip problem. UsewhereHasfor the first andwith()for the second.
Comparison Table
| Lazy (the bug) | Eager with() | Lazy eager load() | withCount() | |
|---|---|---|---|---|
| When it runs | On property access | With the first query | After, on a collection | With the first query |
| Queries for 100 users | 1 + 100 | 2 | 1 (earlier) + 2 | 1 (with a subquery) |
| Use for | Single-model access | Rendering lists | Branching code paths | "How many per row?" |
| Risk | N+1 | Over-eager loading | Missed relations | Extra JOIN/subquery |
Code Example
use App\Models\User;
// BAD — 101 queries for 100 users
$users = User::all();
foreach ($users as $user) {
echo "{$user->name}: {$user->posts->count()} posts\n";
}
// GOOD — 2 queries, same output
$users = User::with('posts')->get();
foreach ($users as $user) {
echo "{$user->name}: {$user->posts->count()} posts\n";
}
Output — the query counts, side by side:
BAD → 101 queries (1 for users + 100 for posts)
GOOD → 2 queries (1 for users + 1 for posts WHERE user_id IN (…))
Mansha Khan: 3 posts
Ali Ahmed: 1 post
Sara Lee: 12 posts
The rendered HTML is identical. Only the database traffic changed — which is exactly why N+1 ships so often: the bug is invisible in the output.
Performance Notes
- Count queries, not lines. The invariant: one query per level of the relation graph you render. Two levels = 3 queries max, three levels = 4.
withCountis a subquery, not a relation.User::withCount('posts')->get()reads$user->posts_countwith no second query and no N+1.whereHasfilters without loading. "Only users with published posts" viawhereHasruns one query with anEXISTS— cheaper than eager-loading everything and filtering in PHP.- Chunk large exports.
User::with('posts')->chunk(500, ...)keeps the memory flat; a single->get()on a giant table is its own scaling problem. - The query log is the ruler.
DB::enableQueryLog()before,DB::getQueryLog()after — if the count per request doesn't stay flat as data grows, you haven't fixed N+1.
Debugging Scenarios
- "My page got slow only in production." — Tests used 10 rows; production has 10,000.
Turn on
Model::preventLazyLoading()locally with realistic data, or watch Debugbar's query count on a real list page. Count per request, thenwith()what's missing. - "
with('posts')didn't change the query count." — You're still touching a nested relation (e.g.$post->comments) or accessing the relation through a different loaded model. Fix each level:with(['posts.comments']). - "
$user->poststhrows in dev but worked before." — Someone enabledModel::preventLazyLoading(). That's the detector working as intended: the relation wasn't eager-loaded. Add it to thewith()call (orload()it where the code branches). - "A conditional eager load returns fewer rows than expected." —
with(['posts' => fn ($q) => $q->where('published', true)])doesn't hide users — it gives some users an empty posts collection. If rows vanished, you've used the closure to filter users instead of posts (that'swhereHas's job).
Quick Revision Notes
- N+1 = N parent rows + one relation query per row = O(N) round-trips.
with('posts')= two queries — parents, thenWHERE user_id IN (…)— joined in PHP.load()= eager-load after the models exist;loadMissing()= only the not-loaded.- Nested
with(['posts.comments'])= one query per level, flat in row count. - Conditional
with(['posts' => fn ($q) => $q->where(...)])constrains only the loaded set. withCount('posts')=$user->posts_countwith a subquery, no N+1.- Detect:
Model::preventLazyLoading()(dev),preventLazyLoading(true, true)(one request), Debugbar /DB::getQueryLog().
Cheat Sheet
N+1 → foreach over all() + $row->relation = 1 + N queries
FIX → Model::with('relation')->get() = 2 queries
AFTER THE FACT → $users->load('relation') = 2 queries
SKIP LOADED → $users->loadMissing('relation')
NESTED → with(['posts.comments']) = 3 queries, always
CONDITIONAL → with(['posts' => fn ($q) => …])
COUNT WITHOUT → withCount('posts') → $row->posts_count
DETECT → Model::preventLazyLoading() (dev only)
ONE REQUEST → Model::preventLazyLoading(true, true) (the $once flag)
RULER → DB::getQueryLog() / Debugbar query countKey Takeaways
Recap
- N+1 is 1 + N queries: one parent query, then one per row via lazy relationship access
- It grows linearly with rows — invisible in tests, fatal in production
with('posts')collapses it to two queries usingWHERE user_id IN (…)load()/loadMissing()eager-load after the fact; nested and conditional loads (with(['posts' => fn ($q) => …])) keep it at one query per levelwithCount()gets per-row totals as a subquery instead of a second N+1- Detect it with
Model::preventLazyLoading()(dev), theonceflag for a single request, or the query log / Debugbar — then never ship without the count in sight
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/8How many queries does User::all() + foreach … $user->posts run for 500 users?
Why is the cost O(N) and not just "a few extra queries"?
Write the fix, and state the exact SQL Eloquent runs for it.
What's the difference between with(), load(), and loadMissing()?
How many queries does User::with(['posts.comments', 'tags'])->get() run — and why?
Write a conditional eager load that only loads published posts.
How would you make N+1 throw an exception in development, and why not in production?
When do you reach for whereHas instead of with()?
What's Next
Lesson 118 — Query Optimization & the Query Builder. whereHas, withCount, chunk,
indexes and EXPLAIN — the rest of making Eloquent fast, without reaching for raw SQL.