Process Huge Datasets Without Running Out of Memory
The moment a background job or command needs to walk a large table, Model::all() becomes a liability. It pulls every row into memory as hydrated model objects, and on a table with millions of rows that exhausts your memory limit and kills the process. Laravel offers four tools for iterating large result sets without that risk. Knowing which to reach for is the whole tip.
chunk() and chunkById()
chunk() retrieves a fixed number of records at a time and hands each batch to a closure, releasing them before fetching the next batch:
User::where('active', true)->chunk(500, function ($users) {
foreach ($users as $user) {
// process $user
}
});
There is a subtle trap: if you modify the column you are ordering by while chunking, records can shift between pages and get skipped. When you are updating rows inside the loop, use chunkById() instead. It paginates on the primary key rather than an offset, which stays stable under modification:
User::where('active', true)->chunkById(500, function ($users) {
$users->each->update(['processed' => true]);
});
lazy() and lazyById()
lazy() gives you the same chunked fetching under the hood but presents the results as a single LazyCollection you iterate with a normal foreach, no nested closure required:
foreach (User::where('active', true)->lazy() as $user) {
// process $user, one at a time, low memory
}
It reads like get() but behaves like chunk(), which usually makes it the most pleasant option. As with chunking, prefer lazyById() when you are updating the rows as you go.
cursor()
cursor() uses a database cursor to fetch a single row at a time, keeping only one hydrated model in memory. It is the lightest on your application's memory:
foreach (User::where('active', true)->cursor() as $user) {
// one model in memory at a time
}
The important caveat is that cursor() keeps a single open connection streaming for the duration, and the database driver may buffer the full result set on its end depending on configuration. It shines for read-only passes over large tables within a single process. For very large jobs where you also want the driver-side memory kept low, the chunked approaches are safer.
Choosing Between Them
The quick guide: use lazy() for a clean, low-memory read-only pass, switch to chunkById() or lazyById() whenever you are writing to the rows you are iterating, and reach for cursor() when you want the absolute minimum application-side memory on a straightforward read. All four beat loading everything with get(), and picking the right one is mostly about whether you are also writing as you go.
Further Reading
- Laravel documentation, "Eloquent: Getting Started", Chunking Results (laravel.com/docs/13.x/eloquent#chunking-results)
- Laravel documentation, "Eloquent: Getting Started", Streaming Results Lazily (laravel.com/docs/13.x/eloquent#streaming-results-lazily)
- Laravel documentation, "Eloquent: Getting Started", Cursors (laravel.com/docs/13.x/eloquent#cursors)