Let's Connect

A laptop screen showing a dashboard of memory and performance metrics for a running process

Bad PHP memory management shows up as one error: `Allowed memory size of 134217728 bytes exhausted (tried to allocate ...)`. It hits an import, a CSV export, or a queue job at row 40,000 — never in your test with 50 rows. The instinct is to bump `memory_limit` to 1G and move on. That is almost always the wrong fix. The real cause is that you loaded an entire dataset into memory at once when you only needed one row at a time. PHP is not leaking; you are hoarding. The fix is to stream — process records in bounded batches or one at a time — so peak memory stays flat no matter how many rows you touch.

I have debugged this in production loan-repayment runs and nightly reconciliation jobs where a single `Model::all()` quietly worked for a year, then the table crossed a few hundred thousand rows and every cron invocation started dying. Nothing changed in the code. The data changed. That is the trap with memory bugs: they are latent until volume finds them.

Why does `get()` blow up but `chunk()` survive?

`User::all()` and `->get()` hydrate every matching row into a full Eloquent model object and hold the entire `Collection` in memory. A model is not cheap — attributes, original-attributes copy for dirty tracking, relations, casts. Multiply a few kilobytes per model by 500,000 rows and you are well past the default 128M limit. The query is fast; the hydration is what kills you. The fix is to never hold the whole result set at once.

You have four tools, and they are not interchangeable. `chunk()` runs `LIMIT/OFFSET` queries and frees each batch before the next; `chunkById()` does the same but pages with a `WHERE id > ?` cursor so it stays correct even if you modify rows inside the loop. `cursor()` returns a `LazyCollection` backed by a PHP generator and a buffered query — one model in memory at a time, but it still pulls the full result set over the wire. `lazy()` combines the generator API with chunked queries underneath, so it is the safe default for huge tables.

app/Console/Commands/ExportInvoices.php
// BAD — hydrates every row, then holds them all. Dies at scale.
foreach (Invoice::all() as $invoice) {
    $this->writeRow($invoice);
}

// GOOD — chunkById: paginated query, batch freed each pass.
// Use chunkById (not chunk) when you update/delete inside the loop,
// otherwise OFFSET drifts and you skip rows.
Invoice::where('status', 'pending')
    ->chunkById(1000, function ($invoices) {
        foreach ($invoices as $invoice) {
            $this->writeRow($invoice);
        }
    });

// GOOD — lazy(): generator API + chunked queries underneath.
// Reads like a flat foreach, stays bounded on millions of rows.
Invoice::where('status', 'pending')
    ->lazy()
    ->each(fn ($invoice) => $this->writeRow($invoice));

Rule of thumb: reach for `lazy()` or `chunkById()` first. Use `cursor()` only when the result set is large in row count but you genuinely need every row and the total payload still fits comfortably — it is one DB round trip but buffers the whole response. For broader query-side wins, make sure the streaming query itself is not the bottleneck with proper database indexing.

How do generators keep file processing flat?

The same principle applies outside Eloquent. `file_get_contents()` on a 2GB log or `fgetcsv` after slurping the whole file into an array loads everything before you touch the first byte. A generator with `yield` produces one value at a time and never builds the full array — memory stays flat whether the file is 1MB or 10GB.

app/Services/CsvStreamer.php
// Stream a huge CSV one row at a time — peak memory stays flat.
function readRows(string $path): \Generator
{
    $handle = fopen($path, 'r');
    if ($handle === false) {
        throw new \RuntimeException("Cannot open {$path}");
    }

    try {
        while (($row = fgetcsv($handle)) !== false) {
            yield $row;
        }
    } finally {
        fclose($handle);
    }
}

// Caller never holds more than one row in memory:
foreach (readRows(storage_path('app/imports/transactions.csv')) as $row) {
    Transaction::create([...]);
}

// Writing large output? Write row by row to a stream, never concat in memory.
$out = fopen('php://output', 'w');
User::lazy()->each(fn ($u) => fputcsv($out, [$u->id, $u->email]));
fclose($out);
A line chart on a monitor showing a memory-usage graph that climbs steadily and then plateaus
Streaming flattens the curve: a chunked or generator-based job holds peak memory roughly constant regardless of row count.

What about references, circular structures, and the GC?

PHP frees memory by refcounting, and the cycle collector only sweeps periodically. In a tight long-running loop you can accumulate garbage faster than the collector runs, especially with objects that hold references back to a parent (circular references). Two habits keep this under control: `unset()` large variables you are done with inside the loop, and call `gc_collect_cycles()` periodically in genuinely long loops to force a sweep. Measure before and after so you are fixing a real problem, not guessing.

app/Jobs/ProcessLargeDataset.php
$peakStart = memory_get_peak_usage(true);
$processed = 0;

Invoice::lazy()->each(function ($invoice) use (&$processed) {
    $report = $this->buildHeavyReport($invoice); // big intermediate array
    $this->store($report);

    unset($report); // drop the big var now, do not wait for scope exit

    // In very long loops, force a cycle collection every N iterations.
    if (++$processed % 500 === 0) {
        gc_collect_cycles();
    }
});

Log::info('memory', [
    'current_mb' => round(memory_get_usage(true) / 1048576, 1),
    'peak_mb'    => round(memory_get_peak_usage(true) / 1048576, 1),
    'delta_mb'   => round((memory_get_peak_usage(true) - $peakStart) / 1048576, 1),
]);
  • `memory_get_usage(true)` reports memory actually allocated from the OS (the real number); pass `false` and you get only what the engine is currently using internally.
  • `memory_get_peak_usage(true)` is the high-water mark — the only number that matters for whether you hit the limit. Log it at the end of every batch job.
  • Eager-loading relations with `with()` inside a chunk is fine; loading them lazily inside the loop fires N+1 queries and inflates memory with hydrated relation models.
  • Disable the query log in long jobs — `DB::disableQueryLog()` — or every executed query is retained in memory for the life of the process.
If a bigger memory_limit fixes your script, you did not fix it — you bought time until the next data growth spike.Md Raihan Hasan

Why do queue workers leak even when the code is clean?

A queue worker is a single PHP process that stays alive across thousands of jobs. Even careful code accumulates a little — static caches, the container resolving and holding bindings, third-party SDK internals. Over hours the resident memory creeps up until the worker is OOM-killed mid-job. The answer is not to chase every byte; it is to recycle the process before it bloats. Bound each worker by job count and wall-clock time, cap its memory, and let a supervisor restart it cleanly.

supervisor worker command
# Recycle the worker after 1000 jobs OR 3600s, whichever comes first.
# --memory=256 makes the worker exit gracefully if it crosses 256 MB.
php artisan queue:work redis \
  --queue=default \
  --max-jobs=1000 \
  --max-time=3600 \
  --memory=256 \
  --tries=3 \
  --sleep=3

The worker exits on its own when it hits any of those bounds; Supervisor (with `autorestart=true`) immediately spawns a fresh one, dropping resident memory back to baseline. This is the standard pattern for keeping long-lived workers healthy — I walk through the full Supervisor config in the Laravel queue workers and Supervisor in production post. When a single job processes a huge dataset, split it across many small jobs with Laravel job batching so no one process has to hold everything, and each job stays comfortably inside its memory bound.

Memory exhaustion in PHP is a design signal, not a configuration knob. The moment you find yourself raising `memory_limit`, stop and ask what you are holding that you do not need. Stream Eloquent with `lazy()` or `chunkById()`, process files through generators, write output row by row, drop big variables as you go, and recycle long-lived workers on a bound. Do that and the same job that died at 40,000 rows will run flat at 40 million — on the default 128M limit, without you ever touching php.ini.