Eager loading vs lazy loading — fetching related data now vs on-demand
Concept
Eager loading vs lazy loading — two strategies for loading related data, with opposite timing.
Lazy loading: Load related data ONLY when it's actually accessed. The relation is fetched on demand — when you first touch $user->orders, a query fires. Convenient (transparent), but dangerous for collections (N+1 problem).
Eager loading: Load related data UPFRONT, before you need it. You specify what to load when writing the query. User::with('orders')->get() — orders are loaded in a separate query that fetches all orders for all retrieved users at once.
The key difference:
- Lazy: Query fires when you access the property. One query per object.
- Eager: Query fires when you call
get(). One query for ALL objects at once.
In Eloquent:
- Lazy loading:
$user->orders— firesSELECT * FROM orders WHERE user_id = ?on first access. - Eager loading:
User::with('orders')— firesSELECT * FROM orders WHERE user_id IN (1,2,3...)once. - Default eager loading:
$with = ['orders']in the model — always eager loads.
Lazy loading (the intentional kind): In non-ORM contexts, "lazy" means deferring expensive computation until needed. PHP generators are lazy — they yield values on demand rather than building the full collection upfront. cursor() in Eloquent is lazy — fetches rows from DB one at a time (streaming).
When to use which:
- Displaying a list with related data → eager loading.
- Single model display where relation MAY not be needed → lazy loading.
- Large datasets →
cursor()(lazy streaming from DB).
Code Example
<?php
// LAZY LOADING — relation fetched on first access
$user = User::find(1);
// No orders query yet
if ($user->isPremium()) {
// Only now is the query fired
$orders = $user->orders; // SELECT * FROM orders WHERE user_id = 1
echo count($orders);
}
// Good: only queries orders when actually needed
// BAD lazy loading (N+1):
$users = User::all(); // SELECT * FROM users
foreach ($users as $user) {
echo $user->profile->avatar; // SELECT * FROM profiles WHERE user_id = ? (per user!)
}
// EAGER LOADING — all relations loaded with the parent query
$users = User::with('profile')->get();
// Query 1: SELECT * FROM users
// Query 2: SELECT * FROM profiles WHERE user_id IN (1, 2, 3, ...)
foreach ($users as $user) {
echo $user->profile->avatar; // already in memory — no query
}
// NESTED eager loading
$orders = Order::with([
'user', // load user
'items', // load items
'items.product', // load product for each item (dot notation for nesting)
'items.product.category', // even deeper
])->get();
// 4 queries: orders, users, items, products (categories if nested)
// CONDITIONAL eager loading
$users = User::with(['orders' => function ($query) {
$query->where('status', 'pending')->orderBy('created_at', 'desc');
}])->get();
// Only loads PENDING orders, ordered by date
// DEFAULT eager loading on the model
class User extends \Illuminate\Database\Eloquent\Model
{
protected $with = ['profile']; // always eager-load profile
}
User::all(); // automatically includes profile in every query
// LAZY — generators / cursor() (streaming lazy loading)
// cursor() fetches one row at a time from DB — memory efficient for large datasets
User::cursor()->each(function (User $user) {
// $user loaded one at a time — generator under the hood
ProcessUser::dispatch($user);
});
// vs get() which loads ALL users into memory at once
// PHP generator lazy evaluation (lazy by nature)
function lazyRange(int $start, int $end): \Generator
{
for ($i = $start; $i <= $end; $i++) {
yield $i; // only computes next value when asked
}
}
$million = lazyRange(1, 1_000_000); // no memory: nothing computed yet
echo $million->current(); // 1 — first value computed on demand