Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
LaravelTroubleshooting guide

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.

Cloudsoft Zone

Updated 7 min read

Long freight train of shipping containers on flat wagons passing through flat countryside
Photograph: Richard Humphrey / CC BY-SA 2.0, via Wikimedia Commons
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:

PHP
// 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:

PHP
$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

MethodQueriesMemoryEager loadingSafe when updating the filtered column
chunk($n, fn)One per chunk (offset-based)One chunkYesNo
chunkById($n, fn)One per chunk (id > last)One chunkYesYes
lazy($n)One per chunkOne chunkYesNo
lazyById($n)One per chunkOne chunkYesYes
cursor()OneOne model, plus the driver's result bufferNoYes (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:

PHP
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:

PHP
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:

PHP
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:

PHP
$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[] = $model inside 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. Call DB::disableQueryLog() in long-running commands and keep debugging tools out of production workers.
  • Relations loaded in the loop. Accessing $order->items on 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 an orderBy orders by the primary key automatically; on the plain query builder you must add orderBy() yourself.
  • Do not use chunk() or lazy() when the loop changes a column you filter or sort on; use the ById variants.
  • cursor() cannot eager load, and is not a cure for very large MySQL result sets.
  • Mass update() and delete() skip model events, so cache invalidation or audit logging tied to events will not run.
  • Raising memory_limit with ini_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() or get(), 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() or lazyById() 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 use lazy() or lazyById(), 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, so saving, saved, updating and updated events and observers do not run.

  • How do I stop queue workers from growing in memory?

    Set --memory, --max-jobs or --max-time on queue:work (or the memory, maxJobs and maxTime options in Horizon) so workers restart regularly, and fix jobs that load large data sets.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Eloquent: Getting Started, Chunking Results and Cursors (Laravel 13.x) — Laravel
  2. Database: Query Builder, Chunking Results (Laravel 13.x) — Laravel
  3. Queues: Queue Workers and Resource Considerations (Laravel 13.x) — Laravel
  4. BuildsQueries concern source, 13.x branch — Laravel (GitHub)
  5. Buffered and Unbuffered queries — PHP Documentation
  6. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.