📁 Volume II: Laravel Performance Tuning Kit

🗄️ Topic 4: Eloquent N+1 Killer

The #1 performance disaster in Laravel (and every ORM).

"101 queries when 2 would suffice.
Your database is drowning. And you don't even know it."
⚠️ THE #1 DISASTER

N+1 is responsible for 80% of slow Laravel applications. It's invisible in development (because you have 10 users), but kills production (when you have 10,000 users). Every senior Laravel developer has debugged this at 3 AM.

🔴 The Problem: What is N+1?

N+1 EXPLAINED

You fetch N records from the database (1 query). Then, for each of those N records, you run another query to fetch related data (N additional queries).

Total queries = 1 + N

If N = 100 users, that's 101 queries to the database.

The Classic Example: Users and Their Posts

👎 THE BAD CODE (N+1)

// Controller
$users = User::all();  // Query 1: SELECT * FROM users

// Blade view (or loop)
@foreach ($users as $user)
    <h2>{{ $user->name }}</h2>
    <p>Posts: {{ $user->posts->count() }}</p>
    //   ↑ Query N: SELECT * FROM posts WHERE user_id = ?
    //   Runs 100 times for 100 users!
@endforeach

// TOTAL QUERIES: 1 + 100 = 101

👍 THE GOOD CODE (Eager Loading)

// Controller
$users = User::with('posts')->get();
// Query 1: SELECT * FROM users
// Query 2: SELECT * FROM posts WHERE user_id IN (1,2,3,...,100)

// Blade view
@foreach ($users as $user)
    <h2>{{ $user->name }}</h2>
    <p>Posts: {{ $user->posts->count() }}</p>
    //   ↑ No new query! Data already loaded.
@endforeach

// TOTAL QUERIES: 2
THE DIFFERENCE
🔴 101 queries (bad)   →   🟢 2 queries (good)

50x fewer database round trips!

🎨 What N+1 Looks Like (Visualized)

│ │ N+1 QUERIES (BAD) — 100 users = 101 queries │ ═══════════════════════════════════════════════════════════════════ │ │ User request arrives │ │ │ ▼ │ ┌─────────────────────────────────────────────────────────────┐ │ │ Query 1: SELECT * FROM users │ │ │ ↓ │ │ │ Returns 100 users │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ ▼ (Loop through users) │ ┌─────────────────────────────────────────────────────────────┐ │ │ Query 2: SELECT * FROM posts WHERE user_id = 1 │ │ │ Query 3: SELECT * FROM posts WHERE user_id = 2 │ │ │ Query 4: SELECT * FROM posts WHERE user_id = 3 │ │ │ ... │ │ │ Query 101: SELECT * FROM posts WHERE user_id = 100 │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ ▼ │ Response sent (after 101 database round trips) │ TIME: ~500ms (5ms per query) │ │ ═══════════════════════════════════════════════════════════════════ │ │ EAGER LOADING (GOOD) — 100 users = 2 queries │ ═══════════════════════════════════════════════════════════════════ │ │ User request arrives │ │ │ ▼ │ ┌─────────────────────────────────────────────────────────────┐ │ │ Query 1: SELECT * FROM users │ │ │ ↓ │ │ │ Returns 100 users │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ ▼ │ ┌─────────────────────────────────────────────────────────────┐ │ │ Query 2: SELECT * FROM posts WHERE user_id IN (1,2,...,100)│ │ │ ↓ │ │ │ Returns ALL posts in ONE query │ │ │ Laravel automatically maps them to users │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ ▼ │ Response sent (after 2 database round trips) │ TIME: ~10ms │

📊 The Real Cost of N+1

Time per query (assuming 5ms database latency):

Number of Users (N)Without Eager Loading (N+1 queries)With Eager Loading (2 queries)Difference
10 users 11 queries = 55ms 2 queries = 10ms 5.5x slower
100 users 101 queries = 505ms 2 queries = 10ms 50x slower
1,000 users 1,001 queries = 5,005ms (5 seconds!) 2 queries = 10ms 500x slower
10,000 users 10,001 queries = 50 seconds! 2 queries = 10ms 5,000x slower — SERVER TIMEOUT
REAL WORLD IMPACT

Most production Laravel apps have multiple N+1 bugs. A page that loads in 200ms on your local machine (with 10 users) can take 20 seconds in production (with 10,000 users). Your users don't wait. They leave. Your server runs out of connections. The site dies.

✅ The Fix: Two Lines of Code That Save Your Life

THE RADAR (DEVELOPMENT ONLY)

Put this in app/Providers/AppServiceProvider.php:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // THROW AN EXCEPTION if lazy loading happens
    Model::preventLazyLoading(!$this->app->isProduction());
}

What this does: In your local development environment, if you accidentally do $user->posts without with('posts'), Laravel will throw an exception and show you exactly where the N+1 is.

🔴 THE GOLDEN RULE OF ELOQUENT:
Never access a relationship without eager loading it first.
preventLazyLoading() enforces this rule automatically.

✅ EAGER LOADING PATTERNS

// One relationship
User::with('posts')->get();

// Multiple relationships
User::with(['posts', 'comments', 'likes'])->get();

// Nested relationships
User::with(['posts.comments'])->get();

// Conditional loading
User::with(['posts' => function ($query) {
    $query->where('published', true);
}])->get();

// Load count only (no data)
User::withCount('posts')->get();
// Then: $user->posts_count

✅ LAZY EAGER LOADING (after the fact)

// Sometimes you can't eager load initially
$users = User::where('active', true)->get();

// But you realize you need posts
// Instead of $user->posts (N+1)...
$users->load('posts');  // One extra query for ALL users

// Or load multiple
$users->load(['posts', 'comments']);

// Load only counts
$users->loadCount('posts');

🎭 The Hidden N+1: When You Think You're Safe

THE TRICKY CASE

You used with() in your controller. You're safe, right? WRONG. What if inside your view, you access a different relationship?

👎 THE HIDDEN N+1

// Controller
$users = User::with('posts')->get();
// You loaded posts, but NOT profiles

// Blade view
@foreach ($users as $user)
    {{ $user->name }}
    {{ $user->posts->count() }}  // ✅ Already loaded
    {{ $user->profile->bio }}    // 💀 N+1! Not loaded!
@endforeach

// TOTAL QUERIES: 1 (users) + 1 (posts) + N (profiles)
// = 2 + N queries!

👍 THE FIX

// Controller
$users = User::with(['posts', 'profile'])->get();
// Load everything you need upfront

// Blade view
@foreach ($users as $user)
    {{ $user->name }}
    {{ $user->posts->count() }}  // ✅ Already loaded
    {{ $user->profile->bio }}    // ✅ Already loaded
@endforeach

// TOTAL QUERIES: 3 queries total
THIS IS WHY preventLazyLoading() IS CRITICAL

Without preventLazyLoading(), the hidden N+1 above would work in development (slowly, but you wouldn't notice with 10 users). In production with 10,000 users, it would add 10,000 extra queries and kill your database.

With preventLazyLoading() enabled in development, Laravel throws an exception immediately: "Attempted to lazy load [profile] on model [User] but lazy loading is disabled."

You fix it before deployment. Production stays fast.

🐇 The Rabbit Hole: Deeply Nested N+1

│ │ DEEP NESTED RELATIONSHIPS │ ═══════════════════════════════════════════════════════════════════ │ │ User → hasMany → Post → hasMany → Comment → belongsTo → Author │ │ Without proper eager loading: │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ Query 1: SELECT * FROM users (N users) │ │ │ Query 2: SELECT * FROM posts WHERE user_id IN (1..N) │ │ │ Query 3: SELECT * FROM comments WHERE post_id IN (1..M) │ │ │ Query 4: SELECT * FROM authors WHERE id IN (1..K) │ │ │ TOTAL: 4 queries │ │ │ (This is actually GOOD — eager loading works!) │ │ └─────────────────────────────────────────────────────────────────┘ │ │ But if you forget one level: │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ $users = User::with('posts.comments')->get(); │ │ │ // posts AND comments are loaded │ │ │ // but NOT authors! │ │ │ │ │ │ @foreach ($users as $user) │ │ │ @foreach ($user->posts as $post) │ │ │ @foreach ($post->comments as $comment) │ │ │ {{ $comment->author->name }} // 💀 N+1! │ │ │ // Each comment triggers a query for its author │ │ └─────────────────────────────────────────────────────────────────┘ │ │ TOTAL QUERIES: 3 + (Number of comments) = DISASTER │
DEEP EAGER LOADING
// Load everything at once, as deep as you need
$users = User::with(['posts.comments.author'])->get();

// Or with constraints
$users = User::with(['posts' => function ($query) {
    $query->where('published', true)
          ->with(['comments' => function ($query) {
              $query->with('author');
          }]);
}])->get();

// Now NO lazy loading anywhere.
// Total queries = number of distinct tables (4-5)
// Regardless of how many records.

🔍 How to Find N+1 Bugs in Your Code

TOOL 1: Laravel Debugbar

Install barryvdh/laravel-debugbar. It shows you:

TOOL 2: Clockwork (Alternative)

Similar to Debugbar. Install itsgoingd/clockwork. Excellent UI, shows timeline of all queries.

TOOL 3: Laravel Telescope (Production-safe)

For production monitoring. Shows you the worst-performing queries, including N+1 patterns.

php artisan telescope:install
MANUAL CHECK

Add this temporarily to see all queries:

// At the top of your controller or route
\DB::enableQueryLog();

// After your code
dd(\DB::getQueryLog());  // Shows every query executed

⚠️ NEVER leave this in production! It stores all queries in memory and will cause OOM crashes.

📝 Topic 4 Summary: Eloquent N+1 Killer

ConceptWithout Eager LoadingWith Eager Loading
Number of queries for N users 1 + N 1 + number of relationships
100 users + posts 101 queries (~500ms) 2 queries (~10ms)
How to enforce Nothing (silent performance killer) Model::preventLazyLoading()
Syntax User::with('posts')->get()
📌 THE RULE: Never trust that you don't have N+1. Always verify with Debugbar. Always use preventLazyLoading() in development. Always eager load everything you need.

One N+1 bug can take your 100ms page to 10 seconds. Don't be that developer.
NEXT TOPIC PREVIEW

Topic 5: Hydration Hell (DB::table() vs Eloquent) — Why Eloquent consumes 5-10x more memory than the Query Builder. When to use Eloquent and when to abandon it for read-only operations.