The #1 performance disaster in Laravel (and every ORM).
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.
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.
// 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
// 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
50x fewer database round trips!
| 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 |
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.
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.
preventLazyLoading() enforces this rule automatically.
// 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
// 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');
You used with() in your controller. You're safe, right? WRONG. What if inside your view, you access a different relationship?
// 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!
// 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
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.
// 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.
Install barryvdh/laravel-debugbar. It shows you:
Similar to Debugbar. Install itsgoingd/clockwork. Excellent UI, shows timeline of all queries.
For production monitoring. Shows you the worst-performing queries, including N+1 patterns.
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.
| Concept | Without Eager Loading | With 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() |
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.