The N+1 query problem is the silent performance killer in Laravel applications. Load 10 posts with their authors? That's 11 database queries. Load 1000? That's 1001 queries. But Laravel's with() method transforms this into just 2 queries, regardless of how many posts you load. Let's explore the elegant optimization that makes this possible.
The N+1 Query Problem
Without eager loading, relationships trigger queries for each model:
// Get all posts
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
echo $post->user->name; // 1 query per post!
}
// Total queries: 1 + N (where N = number of posts)
// For 100 posts: 101 queries
// For 1000 posts: 1001 queries
The problem: Each $post->user access triggers a separate database query.
Query Log
-- Query 1: Load posts
SELECT * FROM posts;
-- Query 2: Load user for post 1
SELECT * FROM users WHERE id = 1;
-- Query 3: Load user for post 2
SELECT * FROM users WHERE id = 2;
-- Query 4: Load user for post 3
SELECT * FROM users WHERE id = 3;
-- ... continues for every post
This scales terribly with data size.
The Solution: Eager Loading
Eager loading loads all relationships upfront with just 2 queries:
// Eager load users with posts
$posts = Post::with('user')->get(); // 2 queries total!
foreach ($posts as $post) {
echo $post->user->name; // No additional queries
}
// Total queries: 2 (regardless of post count)
Query Log with Eager Loading
-- Query 1: Load all posts
SELECT * FROM posts;
-- Query 2: Load all related users at once
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, ...);
Laravel loads all needed users in a single query.
How with() Works Behind the Scenes
Step 1: Load Primary Models
$posts = Post::with('user')->get();
// First, load posts
SELECT * FROM posts;
// Returns: Collection of Post models
Step 2: Extract Foreign Keys
// Behind the scenes
$userIds = $posts->pluck('user_id')->unique();
// Result: [1, 2, 5, 8, 12]
Laravel extracts all unique foreign key values.
Step 3: Load Related Models
// Behind the scenes
$users = User::whereIn('id', $userIds)->get();
// SQL:
SELECT * FROM users WHERE id IN (1, 2, 5, 8, 12);
All related users loaded in one query.
Step 4: Match and Attach
// Behind the scenes
foreach ($posts as $post) {
$user = $users->firstWhere('id', $post->user_id);
$post->setRelation('user', $user);
}
Laravel matches users to posts and caches them in the relations array.
Step 5: Access Without Queries
// Now when you access relationships
foreach ($posts as $post) {
echo $post->user->name; // Uses cached relation, no query!
}
No additional queries needed—relationships are pre-loaded.
Multiple Relationships
Eager load multiple relationships at once:
// Load posts with users AND categories
$posts = Post::with('user', 'category')->get();
// Executes 3 queries:
// 1. SELECT * FROM posts
// 2. SELECT * FROM users WHERE id IN (...)
// 3. SELECT * FROM categories WHERE id IN (...)
foreach ($posts as $post) {
echo $post->user->name; // No query
echo $post->category->name; // No query
}
Each relationship adds one query, not N queries.
Nested Eager Loading
Load relationships of relationships:
// Load posts with users and user's company
$posts = Post::with('user.company')->get();
// Executes 3 queries:
// 1. SELECT * FROM posts
// 2. SELECT * FROM users WHERE id IN (...)
// 3. SELECT * FROM companies WHERE id IN (...)
foreach ($posts as $post) {
echo $post->user->company->name; // No additional queries
}
Dot notation loads nested relationships.
Conditional Eager Loading
Only load when needed:
$posts = Post::query()
->when($includeUser, fn($q) => $q->with('user'))
->when($includeCategory, fn($q) => $q->with('category'))
->get();
Array Syntax for Complex Nesting
$posts = Post::with([
'user' => function ($query) {
$query->select('id', 'name', 'email');
},
'comments' => function ($query) {
$query->where('approved', true);
},
])->get();
Customize the eager loading queries.
Lazy Eager Loading
Load relationships after retrieving models:
// Load posts first
$posts = Post::all();
// Decide later to load users
$posts->load('user');
// Now generates:
// SELECT * FROM users WHERE id IN (...)
// Access without additional queries
foreach ($posts as $post) {
echo $post->user->name;
}
Useful when you don't know upfront which relationships you need.
Eager Loading Specific Columns
Optimize by selecting only needed columns:
$posts = Post::with('user:id,name,email')->get();
// Loads only id, name, email from users
// IMPORTANT: Always include the foreign key (id)!
// SQL:
// SELECT id, name, email FROM users WHERE id IN (...)
Critical: The primary key must always be included.
Common Mistake
// Wrong - missing primary key
Post::with('user:name,email')->get(); // Breaks relationship matching
// Right - includes primary key
Post::with('user:id,name,email')->get(); // Works correctly
Counting Related Models
Count relationships without loading them:
// Count comments per post
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo "{$post->title}: {$post->comments_count} comments";
}
// Generates:
// SELECT posts.*,
// (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id) as comments_count
// FROM posts
Adds a {relationship}_count attribute.
Eager Loading with Constraints
Apply conditions to eager loaded relationships:
// Load only approved comments
$posts = Post::with(['comments' => function ($query) {
$query->where('approved', true)
->orderBy('created_at', 'desc')
->limit(5);
}])->get();
Each post gets only its 5 most recent approved comments.
Exists Queries
Check relationship existence efficiently:
// Load posts that have comments
$posts = Post::has('comments')->get();
// Load posts that have at least 10 comments
$posts = Post::has('comments', '>=', 10)->get();
// SQL generates:
// SELECT * FROM posts
// WHERE EXISTS (
// SELECT * FROM comments WHERE comments.post_id = posts.id
// )
whereHas with Eager Loading
Filter and eager load simultaneously:
// Posts by active users, with user data loaded
$posts = Post::with('user')
->whereHas('user', function ($query) {
$query->where('active', true);
})
->get();
// Filters posts AND eager loads user
Combines filtering and optimization.
Performance Comparison
Without Eager Loading
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
echo $post->user->name; // 100 queries for 100 posts
}
// Total: 101 queries
// Time: ~500-1000ms
With Eager Loading
$posts = Post::with('user')->get(); // 2 queries
foreach ($posts as $post) {
echo $post->user->name; // 0 additional queries
}
// Total: 2 queries
// Time: ~10-20ms
50-100x faster with eager loading!
Load vs LoadMissing
load() - Always Loads
$posts = Post::all();
$posts->load('user'); // Always queries, even if already loaded
loadMissing() - Conditional Load
$posts = Post::with('user')->get();
$posts->loadMissing('user'); // Skips, already loaded
$posts = Post::all();
$posts->loadMissing('user'); // Loads, not yet loaded
loadMissing() checks before loading.
Debugging Eager Loading
Check if Relationship is Loaded
$post = Post::with('user')->first();
if ($post->relationLoaded('user')) {
// User is eager loaded
} else {
// User will lazy load
}
View Query Log
DB::enableQueryLog();
$posts = Post::with('user')->get();
dd(DB::getQueryLog());
// Shows exactly which queries executed
Common Patterns
API Resource with Eager Loading
public function index()
{
return PostResource::collection(
Post::with('user', 'category', 'tags')->paginate(20)
);
}
Dashboard with Multiple Relationships
$user = User::with([
'posts' => fn($q) => $q->latest()->limit(5),
'comments' => fn($q) => $q->latest()->limit(10),
'followers',
])->find(1);
Nested Comments
$posts = Post::with('comments.replies.user')->get();
// Loads posts, their comments, comment replies, and reply users
// Just 4 queries total
When NOT to Eager Load
Small Result Sets
// Only 1 post - eager loading overkill
$post = Post::with('user')->first();
// Better:
$post = Post::first();
$user = $post->user; // Just 1 additional query
Unused Relationships
// Don't eager load if you won't use it
$posts = Post::with('user', 'category', 'tags')->get();
// But only use:
foreach ($posts as $post) {
echo $post->title; // user, category, tags unused
}
Only eager load what you actually need.
Testing Eager Loading
public function test_eager_loading_prevents_n_plus_one()
{
User::factory()->count(5)->create();
Post::factory()->count(10)->create();
DB::enableQueryLog();
$posts = Post::with('user')->get();
// Assert only 2 queries executed
$this->assertCount(2, DB::getQueryLog());
// Access relationships without additional queries
$queryCount = count(DB::getQueryLog());
foreach ($posts as $post) {
$userName = $post->user->name;
}
// No new queries
$this->assertCount($queryCount, DB::getQueryLog());
}
Best Practices
1. Always Eager Load in Loops
// Bad
foreach (Post::all() as $post) {
echo $post->user->name; // N+1
}
// Good
foreach (Post::with('user')->get() as $post) {
echo $post->user->name; // Optimized
}
2. Use Lazy Eager Loading When Conditional
$posts = Post::all();
if ($needUsers) {
$posts->load('user');
}
3. Profile Your Queries
// Use Laravel Telescope or Debugbar
// Identify N+1 problems in development
4. Select Minimal Columns
Post::with('user:id,name')->get();
5. Use withCount for Aggregates
// Don't load all comments just to count
Post::with('comments')->get(); // Loads all comments
// Better
Post::withCount('comments')->get(); // Just counts
Common Mistakes
Mistake 1: Forgetting Primary Key
// Wrong
Post::with('user:name')->get();
// Right
Post::with('user:id,name')->get();
Mistake 2: Over-Eager Loading
// Loading everything "just in case"
Post::with('user', 'category', 'tags', 'comments', 'views')->get();
// Only load what you need
Post::with('user')->get();
Mistake 3: Not Using load() for Conditionals
// Bad - always eager loads
$posts = Post::with('user')->get();
// Good - conditional loading
$posts = Post::all();
if ($condition) {
$posts->load('user');
}
Conclusion
Eager loading with with() is Laravel's elegant solution to the N+1 query problem. By loading relationships upfront in bulk queries, it transforms hundreds or thousands of database queries into just a handful, dramatically improving performance.
Understanding how Laravel extracts foreign keys, loads related models in bulk, and matches them back to parent models helps you write applications that scale effortlessly from 10 to 10,000 records.
Remember: when looping through models and accessing relationships, always use with(). Your database will thank you.
Need help optimizing Laravel query performance or fixing N+1 problems? At NeedLaravelSite, we specialize in Laravel performance optimization and migrations from version 7 to 12. From query analysis to database architecture, we build fast, scalable applications.