LazyCollection: Processing Huge Datasets Without Blowing Up Memory
Collection::make() and collect() are the default reach for iterable data in Laravel, and for the overwhelming majority of use cases that's correct — most collections in a typical app hold a few dozen or a few hundred items. The trouble starts when a collection needs to hold hundreds of thousands of rows, because a regular Collection is backed by a plain PHP array, and that array has to fit in memory all at once. LazyCollection exists for exactly this case.
The core idea: generators instead of arrays
A LazyCollection is backed by a PHP generator rather than an array, so items are produced one at a time as you iterate, instead of all being materialized up front:
use Illuminate\Support\LazyCollection;
$lines = LazyCollection::make(function () {
$handle = fopen(storage_path('imports/transactions.csv'), 'r');
while (($line = fgetcsv($handle)) !== false) {
yield $line;
}
fclose($handle);
});
At the point this code runs, nothing has been read from the file yet — the generator function hasn't executed a single line. Reading only happens as something actually iterates the collection.
Where Eloquent already gives you one
You've likely used LazyCollection without naming it: cursor() returns one.
Invoice::query()->cursor()->each(function ($invoice) {
// one model hydrated at a time, not the whole table at once
});
Compare that to get(), which hydrates every matching row into an array of models before your code ever touches the first one. On a 500,000 row table, that's the difference between constant memory use and an out-of-memory error.
The fluent API still works
The entire point of LazyCollection is that it implements the same Enumerable contract as regular collections, so filter(), map(), take(), and friends all work — they just also stay lazy, building up a pipeline that only actually runs when something forces evaluation:
$total = LazyCollection::make(function () {
$handle = fopen(storage_path('imports/transactions.csv'), 'r');
while (($line = fgetcsv($handle)) !== false) {
yield $line;
}
fclose($handle);
})
->skip(1) // header row
->filter(fn ($row) => $row[2] === 'completed')
->map(fn ($row) => (int) $row[3])
->sum();
Nothing is read from disk until ->sum() is called, at which point the generator runs, filtering and mapping happen row by row, and only the running sum is kept in memory — never the full file.
take() actually stops early
This is the detail that surprises people coming from regular collections: on a LazyCollection, take(10) genuinely stops pulling from the source after 10 items, rather than pulling everything and then slicing:
$firstTenCompleted = LazyCollection::make($generator)
->filter(fn ($row) => $row['status'] === 'completed')
->take(10);
If "completed" rows are near the start of a huge file, this returns quickly without ever reading the rest of the file. A regular Collection couldn't do this even in principle — by the time you'd call ->filter()->take(10) on it, the whole array already exists.
When to reach for it
Use LazyCollection (or Eloquent's cursor() / lazy()) specifically when the number of items is the problem — large file imports, full-table exports, log processing. Don't reach for it by default: the generator overhead and inability to know the count upfront (count() on an unconsumed lazy collection has to fully iterate it) make it a worse fit than a regular collection for the normal case of "a few hundred items I'm about to loop over once." Save it for the moment get() would genuinely exhaust memory.