Under the Hood

Eager Loading - How with() Prevents N+1 Queries

Learn how Laravel's eager loading with with() prevents N+1 query problems. Understand query optimization, relationship loading, and performance improvements in Laravel 12.

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

|
15-Dec-2025
8 min read
Eager Loading - How with() Prevents N+1 Queries

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.


Article Tags

laravel eager loading with method laravel n+1 query problem optimize eloquent queries laravel eager load relationships laravel

About the Author

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

Founder & CEO at CentoSquare | Creator of NeedLaravelSite | Helping Businesses Grow with Cutting-Edge Web, Mobile & Marketing Solutions | Building Innovative Products