Eager Loading Is Not Always the Answer in Laravel
Sometimes a 40-join Eloquent query is worse than lazy loading. Here's when I reach for chunked lazy loading instead.
The conventional wisdom in Laravel land is "always eager load your relationships." Avoid the N+1 problem. Use with(). Done. And most of the time, that advice is correct. But I burned a queue worker to the ground last year following it blindly, and I want to talk about what actually happened.
The Setup
I was building a nightly export job for an industrial client — a manufacturer tracking job orders, line items, materials, suppliers, compliance certs, and inspection records. The job pulled every open order from the past 90 days and transformed them into a flat CSV for their ERP integration. Classic ETL work.
The Eloquent graph looked roughly like this:
$orders = Order::with([
'lineItems',
'lineItems.material',
'lineItems.material.supplier',
'lineItems.material.supplier.certifications',
'lineItems.inspections',
'lineItems.inspections.inspector',
'customer',
'customer.contacts',
'shippingAddress',
'notes',
])->whereBetween('created_at', [$start, $end])->get();
On my dev box with a few hundred orders, fine. In production, 90 days of orders meant roughly 12,000 records. With all those nested relationships, Eloquent was hydrating somewhere north of 400,000 model instances in a single get() call. The worker hit its memory limit, PHP allocated everything it could, and the job died silently. The ERP team thought the feed was just running late. It hadn't run in four days.
What Eager Loading Actually Does to Memory
This is the part people gloss over. When you call Order::with([...])->get(), Eloquent doesn't stream. It runs the base query, collects all the primary keys, fires batched WHERE id IN (...) queries for each relationship level, and then stitches the entire object graph together in memory. Every model, every relationship, every attribute — all of it sitting in a PHP array until the garbage collector gets around to cleaning it up.
For small result sets, that's fine. For 12,000 orders with 10 nested relationships each, you're asking PHP to hold an enormous object graph simultaneously for a job that's processing records one at a time anyway.
The fix wasn't to increase the memory limit. That's a crutch. The fix was to stop loading things I wasn't using yet.
Chunked Lazy Loading: The Actual Pattern
Laravel has LazyCollection and chunk() for exactly this scenario. The idea is simple: load a small batch of parent records, process them, let PHP free that memory, load the next batch. Combine it with per-chunk eager loading and you get the query efficiency you need without blowing the heap.
Here's the refactored version of that job:
use Illuminate\Support\LazyCollection;
Order::whereBetween('created_at', [$start, $end])
->chunkById(200, function (Collection $orders) use ($exporter) {
// Eager load relationships only for this chunk of 200
$orders->load([
'lineItems',
'lineItems.material',
'lineItems.material.supplier',
'lineItems.material.supplier.certifications',
'lineItems.inspections',
'lineItems.inspections.inspector',
'customer',
'customer.contacts',
'shippingAddress',
'notes',
]);
foreach ($orders as $order) {
$exporter->writeRow($order);
}
// $orders goes out of scope here, chunk is eligible for GC
});
A chunk of 200 orders with full relationship loading is maybe 6,000–8,000 model instances at peak. Process them, write the rows, move on. Memory stays flat across the entire job instead of ballooning proportionally to result set size.
Notice I'm still using load() inside the chunk — I'm not lazy-loading individual relationships in the foreach. That would reintroduce N+1 queries. The pattern is: batch your records, eager-load within the batch, iterate.
cursor() vs chunk(): Know the Difference
Laravel also has cursor(), which uses a LazyCollection backed by a PHP generator to stream results one row at a time from the database. Memory-wise it's even leaner for the base query. But it has a real gotcha: you can't call load() on a generator-backed collection because you don't have a full batch of IDs to send to the WHERE IN query until you've iterated far enough.
// This looks fine but you're back to N+1
Order::cursor()->each(function (Order $order) {
// Each access to $order->lineItems fires a separate query
foreach ($order->lineItems as $item) { ... }
});
If your relationship graph is shallow and you genuinely only need one or two attributes from a child relationship, cursor() with join() or a manual subquery can work. But for deep, branchy graphs like the one above, chunkById() with load() is the right tool.
Also: use chunkById() rather than chunk(). The offset-based chunk() is a footgun on large tables — as rows get processed or inserted mid-job, the OFFSET shifts and you can skip records or process them twice. chunkById() uses WHERE id > ? pagination, which is stable.
The Gotchas That Will Bite You
Chunk size matters more than you'd think. Too small (say, 25) and you're hammering the database with many round trips. Too large (say, 2000) and you're back to memory problems. I've landed on 200–500 as a reasonable default for complex relationship graphs on Postgres, but profile your own workload.
load() on a collection fires queries synchronously. All the WHERE IN queries for a chunk of 200 run before your foreach starts. That's correct behavior, but if you're measuring query time per record in a telescope trace, it'll look weird — you'll see a burst of queries, then nothing. Don't let that confuse you into thinking the eager load isn't working.
Watch your transaction scope. If the job wraps each chunk in a transaction for atomicity, the relationship load() calls happen inside that transaction. On MySQL with long-running jobs, this can hold locks longer than you expect. On Postgres it's less of an issue, but worth knowing.
chunkById() assumes a sortable, unique ID column. If you're chunking on a non-standard primary key or a UUID column without an index, the pagination queries will be slow. Make sure there's an index on whatever column you're chunking by.
When I'd Reach for Chunked Lazy Loading
- Queue workers and scheduled jobs processing large result sets (more than a few hundred rows with deep relationships)
- Any job running in a container or Vapor-style serverless environment where memory limits are hard and non-negotiable
- Export/ETL pipelines where you're writing output sequentially and don't need the full object graph in memory at once
- Jobs that have historically been killed by OOM without a clear query to blame
When I Wouldn't
For synchronous HTTP requests, plain with() is still almost always the right answer. You're loading a handful of records, the user is waiting, and the overhead of chunking adds complexity you don't need. The N+1 problem is real and eager loading solves it correctly for that use case.
Same for anything where you genuinely need the full collection in memory at once — sorting across a relationship, building a pivot table, anything where the logic requires global knowledge of the result set. Chunking doesn't work there; you need all the data or you need to rethink the approach at the database level (a raw query with proper joins, a materialized view, something).
Also: if your dataset is small and controlled — a lookup table, a config model, reference data — don't overthink it. Load it once, cache it, move on.
The Real Lesson
Eager loading solves a query count problem. It does not solve a memory problem. In fact, for large datasets it creates one. These are two different axes and collapsing them into "just use with()" is the mistake.
Before I reach for eager loading on anything that runs in a worker, I ask: how many model instances am I about to ask PHP to hold at once, and do I actually need all of them at the same time? If the answer to the second question is no — and in ETL-style jobs it usually isn't — chunking with per-batch loading is the right move.
The ERP export job now runs in under three minutes, uses a flat 80MB of memory throughout, and hasn't missed a run since I refactored it. That's the benchmark I care about.
Need help shipping something like this? Get in touch.