Eager Loading & the N+1 Problem
The single most common Laravel performance bug — what causes it, and four ways to kill it.
The N+1 problem is the performance bug every Laravel developer meets. You load 50 posts (1 query), then loop and touch $post->author->name — and Laravel quietly runs 50 more queries, one per author. That is 51 queries where 2 would do.
The fix: eager load up front
// Lazy — 1 + N queries
$posts = Post::all();
foreach ($posts as $p) { echo $p->author->name; }
// Eager — 2 queries total
$posts = Post::with('author')->get();
foreach ($posts as $p) { echo $p->author->name; }The full toolkit
with()— load relations up front; nest with dot notation and constrain with closures.load()— eager-load onto models you already fetched.withCount()/withSum()— aggregates without loading the rows.loadMissing()— load only if not already present.
Best of all, you can make N+1 impossible to ship. Model::preventLazyLoading() throws the moment a relation is accessed without being eager-loaded.
Example
<?php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Throw on lazy loading everywhere EXCEPT production.
Model::preventLazyLoading(! $this->app->isProduction());
// In production, log the violation instead of crashing:
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
logger()->warning("Lazy load: {$relation} on " . $model::class);
});
}
}
// --- A query that stays fast under load ---
$posts = \App\Models\Post::query()
->with([
// Constrain + select only what the view needs.
'author:id,name,avatar_path',
'comments' => fn ($q) => $q->latest()->limit(3)->with('user:id,name'),
'tags:id,name',
])
->withCount('comments') // comments_count without loading them all
->withExists('likes as liked_by_me', fn ($q) => $q->whereKey(auth()->id()))
->latest()
->paginate(20);
foreach ($posts as $post) {
echo $post->author->name; // already loaded
echo $post->comments_count; // aggregate, no extra query
}When to use it
- A posts listing page eager loads author and tags for all posts in two extra queries instead of firing N queries inside the foreach loop.
- A nested eager load like with('posts.comments') pre-fetches a user's posts and all comments on those posts in three total queries.
- A conditional eager load uses load() on a model already retrieved to fetch a relationship only when a specific flag is set by the request.
More examples
with() to prevent N+1
with('author') tells Eloquent to load all related authors in one additional IN query after fetching the posts.
// N+1: 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // query per post
}
// Eager load: always 2 queries total
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // no extra query
}Nested and multi-relationship eager loading
Loads multiple relationships in one call; the closure on posts.comments constrains the eager load to the 3 most recent comments per post.
$users = User::with([
'profile', // hasOne
'posts', // hasMany
'posts.tags', // nested: tags for each post
'posts.comments' => fn($q) => $q->latest()->limit(3),
])->paginate(20);withCount and loadMissing
withCount() appends an aggregate column; loadMissing() fetches a relationship only if it has not already been eager loaded.
// Add a comments_count column to each post without loading comments
$posts = Post::withCount('comments')->get();
echo $posts->first()->comments_count;
// Conditionally load a relationship on already-retrieved models
if ($request->boolean('include_author')) {
$posts->loadMissing('author');
}
Discussion