
Laravel Eager Loading vs Lazy Loading
Modern Laravel applications often become slow not because of PHP itself, but because of inefficient database access patterns. One of the most common causes is improper relationship loading in Eloquent ORM.
If you have ever seen:
- Hundreds of duplicated SQL queries
- Extremely slow API responses
- High database CPU usage
- Long page rendering times
- Excessive memory usage
Then there is a high chance your application is suffering from the infamous N+1 query problem.
This article dives deep into:
- Lazy loading internals
- Eager loading internals
- SQL generated by Eloquent
- Performance benchmarks
- Memory tradeoffs
- Production optimization techniques
- Advanced Eloquent loading patterns
Understanding Eloquent Relationships
Laravel Eloquent relationships allow models to access related data naturally.
Example:
phpclass User extends Model { public function posts() { return $this->hasMany(Post::class); } }
Now you can access:
php$user->posts
This looks simple.
But what actually happens behind the scenes depends on whether Laravel uses:
- Lazy Loading
- Eager Loading
The difference can dramatically affect application performance.
What is Lazy Loading?
Lazy loading means Laravel only loads relationships when they are accessed.
Example:
php$users = User::all(); foreach ($users as $user) { echo $user->posts->count(); }
At first glance, this code looks harmless.
But internally Laravel executes:
sqlSELECT * FROM users;
Then for EACH user:
sqlSELECT * FROM posts WHERE user_id = 1; SELECT * FROM posts WHERE user_id = 2; SELECT * FROM posts WHERE user_id = 3;
If there are 1000 users:
- 1 query for users
- 1000 queries for posts
Total:
txt1001 SQL queries
This is called the:
N+1 Query Problem
Where:
txt1 main query + N relationship queries
This is one of the most common Laravel performance problems.
Why Lazy Loading Becomes Slow
Every database query has overhead:
- Network roundtrip
- Query parsing
- Query planning
- Disk reads
- Connection management
- Memory allocation
Even if each query only takes:
txt3ms
1000 queries become:
txt3000ms = 3 seconds
And that is before rendering JSON or HTML.
On production systems with:
- millions of rows
- concurrent users
- remote databases
- API aggregation
The problem becomes catastrophic.
What is Eager Loading?
Eager loading tells Laravel to load relationships immediately.
Example:
php$users = User::with('posts')->get(); foreach ($users as $user) { echo $user->posts->count(); }
Now Laravel executes:
sqlSELECT * FROM users;
Then:
sqlSELECT * FROM posts WHERE user_id IN (1,2,3,4,5...);
Instead of 1001 queries, Laravel only executes:
txt2 queries
Massive performance improvement.
How Eloquent Eager Loading Works Internally
When using:
phpUser::with('posts')->get();
Laravel internally:
- Retrieves all users
- Extracts all user IDs
- Executes ONE relationship query
- Hydrates related models
- Maps relationships in memory
Conceptually:
php$userIds = $users->pluck('id'); $posts = Post::whereIn('user_id', $userIds)->get();
Then Laravel groups posts by:
phpuser_id
And attaches them to parent models.
This drastically reduces database roundtrips.
SQL Comparison
Lazy Loading
php$users = User::all(); foreach ($users as $user) { $user->posts; }
Queries:
sqlSELECT * FROM users; SELECT * FROM posts WHERE user_id = 1; SELECT * FROM posts WHERE user_id = 2; SELECT * FROM posts WHERE user_id = 3;
Eager Loading
php$users = User::with('posts')->get();
Queries:
sqlSELECT * FROM users; SELECT * FROM posts WHERE user_id IN (1,2,3);
Benchmark Example
Assume:
- 500 users
- Each user has 20 posts
- Database latency: 4ms/query
Lazy Loading
txt501 queries Γ 4ms = 2004ms
Eager Loading
txt2 queries Γ 4ms = 8ms
Even accounting for hydration overhead, eager loading wins massively.
Nested Eager Loading
Laravel supports nested relationships.
Example:
php$users = User::with('posts.comments')->get();
Laravel executes:
sqlSELECT * FROM users; SELECT * FROM posts WHERE user_id IN (...); SELECT * FROM comments WHERE post_id IN (...);
This avoids nested N+1 problems.
Without eager loading:
txtUsers β Posts β Comments
Can explode into thousands of queries.
Constrained Eager Loading
You can filter relationships during eager loading.
Example:
php$users = User::with([ 'posts' => function ($query) { $query->where('published', true) ->latest() ->limit(5); } ])->get();
Benefits:
- Reduces memory usage
- Reduces query payload
- Improves API response size
- Improves serialization speed
This is extremely useful in production APIs.
Selecting Specific Columns
Avoid loading unnecessary columns.
Bad:
phpUser::with('posts')->get();
Better:
phpUser::with('posts:id,user_id,title') ->select('id', 'name') ->get();
This reduces:
- Memory usage
- Transfer size
- JSON serialization cost
- Cache footprint
Especially important for:
- mobile APIs
- large datasets
- analytics dashboards
Lazy Eager Loading
Laravel also supports:
phpload()
Example:
php$users = User::all(); $users->load('posts');
This is called:
txtLazy Eager Loading
Useful when:
- relationship loading is conditional
- repository layers decide later
- service classes enrich models dynamically
loadMissing()
Avoid duplicate relationship loading.
Example:
php$users->loadMissing('posts');
Laravel only loads the relationship if not already loaded.
Useful in:
- reusable services
- API transformers
- DTO mapping
- package development
Prevent Lazy Loading in Production
Laravel can prevent accidental lazy loading.
In:
phpAppServiceProvider
Add:
phpuse Illuminate\Database\Eloquent\Model; public function boot() { Model::preventLazyLoading(! app()->isProduction()); }
Now Laravel throws exceptions when lazy loading occurs unexpectedly.
This is extremely useful for detecting N+1 problems during development.
Detecting N+1 Queries
Useful tools:
Laravel Debugbar
bashcomposer require barryvdh/laravel-debugbar --dev
Shows:
- executed queries
- duplicate queries
- execution time
- memory usage
Laravel Telescope
bashcomposer require laravel/telescope --dev
Excellent for:
- query inspection
- slow query monitoring
- request profiling
- production debugging
Advanced Relationship Optimization
withCount()
Instead of loading entire relationships:
Bad:
php$users = User::with('posts')->get(); foreach ($users as $user) { echo $user->posts->count(); }
Better:
php$users = User::withCount('posts')->get();
Generated SQL:
sqlSELECT users.*, ( SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id ) AS posts_count FROM users;
Much more efficient.
withExists()
Check relationship existence efficiently.
php$users = User::withExists('posts')->get();
Avoids loading entire collections.
withSum(), withAvg(), withMax()
Aggregate relationships efficiently.
Example:
phpUser::withSum('orders', 'total')->get();
Instead of:
php$user->orders->sum('total');
Which may load thousands of rows unnecessarily.
Memory Tradeoffs of Eager Loading
Eager loading is not always perfect.
Potential issue:
phpUser::with('posts')->get();
If:
- 1 million users
- each user has 100 posts
Laravel may consume huge amounts of memory.
Sometimes chunking is better.
Example:
phpUser::with('posts') ->chunk(100, function ($users) { // process chunk });
Or use:
phpcursor()
for streaming.
Common Production Mistakes
1. Eager Loading Everything
Bad:
phpUser::with([ 'posts', 'comments', 'roles', 'permissions', 'notifications', 'activities' ])->get();
This can create:
- huge memory usage
- oversized JSON payloads
- slow serialization
Load only what is necessary.
2. API Resource Hidden N+1
Example:
phpreturn UserResource::collection(User::all());
Then inside resource:
php$this->posts
This silently creates N+1 queries.
Correct approach:
phpUser::with('posts')->get();
3. Blade Template N+1
Example:
blade@foreach($users as $user) {{ $user->posts->count() }} @endforeach
Always eager load before rendering views.
Eager Loading Polymorphic Relationships
Example:
phpComment::with('commentable')->get();
Laravel handles:
- posts
- videos
- articles
through polymorphic eager loading.
You can optimize further using:
phpmorphWith()
Example:
phpActivityFeed::with([ 'parentable' => function ($morphTo) { $morphTo->morphWith([ Post::class => ['author'], Video::class => ['channel'], ]); } ])->get();
Advanced but extremely powerful.
Database Indexing Still Matters
Even with eager loading, indexes are critical.
Always index:
txtforeign keys
Example:
sqlCREATE INDEX posts_user_id_index ON posts(user_id);
Without indexes:
sqlWHERE IN (...)
queries become slow.
Recommended Production Strategy
Use Eager Loading When:
- displaying lists
- building APIs
- rendering dashboards
- exporting reports
- processing relationships repeatedly
Use Lazy Loading When:
- relationship may not be needed
- single model detail pages
- low-frequency operations
- debugging
Real-World Example
Bad API Endpoint
phpRoute::get('/users', function () { return User::all(); });
Then frontend requests:
phpuser.posts
This creates hidden N+1 problems.
Better API Endpoint
phpRoute::get('/users', function () { return User::with([ 'posts:id,user_id,title', 'roles:id,name' ]) ->select('id', 'name', 'email') ->get(); });
Much more scalable.
Measuring Query Performance
Useful methods:
phpDB::listen(function ($query) { logger($query->sql); });
Or:
phpDB::enableQueryLog();
Then:
phpDB::getQueryLog();
Useful for performance audits.
Final Thoughts
Laravel Eloquent is extremely powerful, but ORM convenience can hide expensive database operations.
Understanding eager loading vs lazy loading is essential for:
- scalable APIs
- high-performance dashboards
- enterprise Laravel systems
- microservices
- analytics platforms
- SaaS applications
The most important takeaway:
txtDatabase queries are usually more expensive than PHP execution.
-
Reducing query count often produces the largest performance gains.
-
Use eager loading strategically.
-
Profile your queries.
-
Measure memory usage.
-
Avoid N+1 problems before they reach production.