Fix Laravel memory exhausted errors with chunk, lazy and cursor
Why Laravel jobs and commands hit "Allowed memory size exhausted" on large tables, and how chunkById, lazyById, cursor and set-based queries fix it for good.
Updated 7 min read

In this article
Laravel memory exhausted errors almost always mean a whole table was loaded into PHP. The fixes are batching with chunkById() or lazyById(), the Laravel chunk vs cursor choice for one model at a time, streamed exports, set-based SQL and hunting hidden leaks.
The error Allowed memory size of 134217728 bytes exhausted in a Laravel job or Artisan command almost always means one thing: the code loaded a whole table into PHP at once, usually with Model::all() or ->get(). The fix is to process rows in batches with chunkById() or lazyById(), stream with cursor() when you need one model at a time, or push the work into a single SQL statement. Raising memory_limit only delays the next crash.
This guide shows how to confirm the cause, choose the right method, and find the less obvious leaks. Methods and behaviour were checked against the Laravel 13.x Eloquent and query builder documentation and the framework source on 10 October 2026.
Why it happens
An Eloquent model is a full PHP object with attributes, original values, casts and relationship arrays. Hydrating 300,000 of them can need gigabytes, while PHP's default memory_limit is 128 MB and queue workers restart at 128 MB by default. The code usually looks harmless:
// Loads every row and every model into memory before the loop starts
foreach (Order::where('status', 'pending')->get() as $order) {
$order->update(['status' => 'expired']);
}It works on a developer laptop with 500 rows and fails in production with 500,000.
Step 1: confirm with numbers
Before changing code, measure peak memory around the suspect block. In a command or a Tinker session:
$before = memory_get_peak_usage(true);
$count = 0;
Order::where('status', 'pending')->lazyById(1000)->each(function (Order $order) use (&$count) {
$count++;
});
$after = memory_get_peak_usage(true);
logger()->info('Expired-order scan', [
'rows' => $count,
'peak_mb' => round($after / 1048576, 1),
'delta_mb' => round(($after - $before) / 1048576, 1),
]);Run it once with get() and once with the batched version. If the peak grows with the number of rows, you are holding them all; if it stays flat, batching is working. For timing, Laravel's Illuminate\Support\Benchmark class (Benchmark::dd() or Benchmark::value()) is handy as well.
Step 2: choose the right batching method
| Method | Queries | Memory | Eager loading | Safe when updating the filtered column |
|---|---|---|---|---|
chunk($n, fn) | One per chunk (offset-based) | One chunk | Yes | No |
chunkById($n, fn) | One per chunk (id > last) | One chunk | Yes | Yes |
lazy($n) | One per chunk | One chunk | Yes | No |
lazyById($n) | One per chunk | One chunk | Yes | Yes |
cursor() | One | One model, plus the driver's result buffer | No | Yes (no paging) |
The default chunk size for lazy(), lazyById() and eachById() is 1,000.
chunkById for updates
The example above filters on status and then changes status. With plain chunk(), which pages with offsets, every update shrinks the result set and the next page skips rows. The documentation recommends chunkById() in exactly this case, because it pages by primary key:
use Illuminate\Database\Eloquent\Collection;
Order::where('status', 'pending')
->where('created_at', '<', now()->subDays(30))
->chunkById(500, function (Collection $orders) {
foreach ($orders as $order) {
$order->update(['status' => 'expired']);
}
});If you combine orWhere conditions with chunkById or lazyById, wrap them in a closure, because these methods add their own where on the key. Without the grouping, a OR b AND id > ? becomes a different query from the one you meant. Return false from the callback to stop early.
lazyById for readable pipelines
lazyById() runs the same batched queries but hands you a flat LazyCollection, so you can chain collection methods without holding everything:
User::query()
->with('subscription')
->whereNotNull('email_verified_at')
->lazyById(1000)
->filter(fn (User $user) => $user->subscription?->isActive())
->each(fn (User $user) => SendRenewalReminder::dispatch($user));Eager loading with with() works here: each batch loads its relationships in one extra query, so you avoid both the memory problem and N+1 queries.
cursor for one model at a time
cursor() runs a single query and hydrates one model per iteration using a PHP generator. It cannot eager load relationships. The documentation also warns that it can still run out of memory on very large results, because PHP's PDO MySQL driver buffers the full raw result set by default. For millions of rows on MySQL, prefer lazyById().
Step 3: stream exports instead of building them
CSV exports are a classic culprit: the code builds an array of every row, then writes the file. Stream instead:
use App\Models\Order;
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'customer', 'total', 'created_at']);
Order::query()
->select(['id', 'customer_name', 'total_cents', 'created_at'])
->lazyById(2000)
->each(function (Order $order) use ($out) {
fputcsv($out, [
$order->id,
$order->customer_name,
number_format($order->total_cents / 100, 2, '.', ''),
$order->created_at->toDateTimeString(),
]);
});
fclose($out);
}, 'orders.csv');Selecting only the columns you write keeps each batch small. For very large exports, move the same code into a queued job that writes to storage and emails a link.
Step 4: let the database do set-based work
The fastest way to update a million rows is not to load them at all. If the change does not need model events, casts or per-row PHP logic, use one statement:
$expired = Order::where('status', 'pending')
->where('created_at', '<', now()->subDays(30))
->update(['status' => 'expired']);This returns the number of affected rows and uses almost no PHP memory. Be aware of the trade-off: Eloquent model events (saving, updated, observers) do not fire for mass updates. The same applies to delete() on a query. For reads, pluck('email', 'id') or toBase() return plain values or stdClass rows instead of full models, which is much lighter when you only need a few columns.
For huge single updates that would lock a table for too long, combine both ideas: page the IDs with chunkById and run one whereIn('id', ...)->update() per chunk.
Step 5: find the hidden leaks
If memory still grows after batching, something is keeping references to old rows:
- Accumulating arrays. Code like
$results[] = $modelinside the loop rebuilds the whole table in memory. Write results out (to a file, the database or a queue) per batch. - Query logging. If something calls
DB::enableQueryLog(), or a debugging package records every query, a long command stores thousands of SQL strings. CallDB::disableQueryLog()in long-running commands and keep debugging tools out of production workers. - Relations loaded in the loop. Accessing
$order->itemson each model keeps every loaded relation attached until the batch is released. Eager load only what the loop needs, with column lists. - Static caches and singletons. Your own static arrays or memoized services live for the entire process, which in a queue worker can be hours.
- Collections in jobs. Passing a large Eloquent collection into a queued job serializes it into the payload. Pass IDs or a query constraint instead, and let the job batch its own reads.
Queue workers and memory limits
Queue workers are long-lived processes. php artisan queue:work --memory=256 sets the threshold at which a worker exits after finishing its current job, and Horizon has an equivalent memory option per supervisor. These limits are a safety net that restarts a slowly leaking worker; they do not stop a single job from exceeding PHP's memory_limit. Combine them with --max-jobs or --max-time so workers are recycled regularly, and fix the job itself with the batching methods above.
Gotchas checklist
chunk()on an Eloquent query without anorderByorders by the primary key automatically; on the plain query builder you must addorderBy()yourself.- Do not use
chunk()orlazy()when the loop changes a column you filter or sort on; use theByIdvariants. cursor()cannot eager load, and is not a cure for very large MySQL result sets.- Mass
update()anddelete()skip model events, so cache invalidation or audit logging tied to events will not run. - Raising
memory_limitwithini_set()is a last resort for a one-off command, not a fix.
What to do next: search your codebase for ::all() and ->get() inside commands, jobs and exports, add a peak-memory log line to each long-running task, and convert the worst offenders to lazyById() or a single SQL update.
Frequently asked questions
What causes "Allowed memory size exhausted" in Laravel?
Most often, loading a large result set into memory with
all()orget(), or collecting results into an array inside a loop. Each Eloquent model is a full PHP object, so hundreds of thousands of them exceed the default 128 MB limit.Should I just increase memory_limit?
Only as a temporary measure for a one-off task. The memory needed grows with your table, so the error returns. Batching with
chunkById()orlazyById()keeps memory flat no matter how many rows there are.What is the difference between chunk and chunkById?
chunk()pages with offsets, so rows can be skipped if your loop changes the filtered column.chunkById()pages by primary key (id > last id), which is safe when updating and faster on large tables.When should I use cursor() instead of lazy()?
Use
cursor()for moderate result sets where you need one model at a time and no eager loading. For very large MySQL tables uselazy()orlazyById(), because PDO buffers the whole raw result for a cursor.Does a mass update with Eloquent fire model events?
No.
Model::where(...)->update([...])runs one SQL statement and does not retrieve models, sosaving,saved,updatingandupdatedevents and observers do not run.How do I stop queue workers from growing in memory?
Set
--memory,--max-jobsor--max-timeonqueue:work(or thememory,maxJobsandmaxTimeoptions in Horizon) so workers restart regularly, and fix jobs that load large data sets.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Eloquent: Getting Started, Chunking Results and Cursors (Laravel 13.x) — Laravel
- Database: Query Builder, Chunking Results (Laravel 13.x) — Laravel
- Queues: Queue Workers and Resource Considerations (Laravel 13.x) — Laravel
- BuildsQueries concern source, 13.x branch — Laravel (GitHub)
- Buffered and Unbuffered queries — PHP Documentation
- memory_get_peak_usage — PHP Documentation
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

TutorialDevOps
Laravel backup restore: recover the application, database and uploaded files together
Plan Laravel backup restore around a recoverable set of code, database, uploaded objects and protected keys, then verify the complete application in isolation.
By Cloudsoft Zone

TutorialDevOps
Laravel pending migrations: deploy safely without breaking the running application
Review Laravel pending migrations before activation: confirm the target database, check code compatibility, rehearse schema changes and verify recovery without resetting data.
By Cloudsoft Zone

TutorialDevOps
Laravel log management: rotation, retention and safe production debugging
Set up Laravel log management with deliberate channels, retention, file ownership, rotation responsibility and useful incident context without recording secrets.
By Cloudsoft Zone
