A queue worker starts at 30MB. Six hours and 40,000 jobs later, it’s sitting at 1.2GB and gets killed by the OOM killer mid-job. The on-call engineer restarts it, the graph resets to 30MB, and it climbs again. Nobody touched the code between the good week and the bad week.
This is the most common PHP memory story, and it confuses people because PHP has automatic garbage collection. The truth is: PHP’s memory manager cleans up perfectly at the end of every request. The leaks happen in the code that runs inside one long-lived process, request after request, job after job, never getting that clean reset.
How PHP actually manages memory
Every PHP variable is a zval (Zend value) with a refcount. When the refcount hits zero, the memory is freed immediately. This handles almost everything — until you get a circular reference.
The key fact that causes most confusion: at the end of a normal PHP-FPM request, none of this matters. The whole process memory (the “arena”) is thrown away and rebuilt for the next request, cycles and all. PHP’s garbage collector exists to stop memory from ballooning during one very long execution — a CLI script, a queue worker loop, or a long-running Swoole/RoadRunner/Octane worker that serves thousands of requests without restarting.
That’s the whole story in one line: PHP-FPM leaks don’t matter (new process every request). Long-running PHP leaks do (same process forever).
Real-world failure scenarios
| # | Scenario | What actually happens | Root cause |
|---|---|---|---|
| 1 | Queue worker OOM after hours | A Laravel queue:work process (not --once) grows until the OS kills it mid-job |
Static caches, event listeners, or Eloquent models accumulating across jobs in the same process |
| 2 | Laravel Octane / Swoole worker degrades | Response times creep up, then a worker restarts itself under memory pressure | Framework singletons holding request-scoped data (Auth::user(), request objects) between requests |
| 3 | CLI import script crashes at row 800,000 | A “process all rows” script that was fine on staging (10k rows) dies on production (2M rows) | Eloquent’s query builder loading the whole table into an array before iterating |
| 4 | Symfony event dispatcher circular leak | A long-running console command slowly leaks despite unset() everywhere |
Closures capturing $this, registered as listeners, never deregistered — classic reference cycle |
| 5 | Image processing service leaks per request | A worker resizing uploads gets slower and fatals with “Allowed memory size exhausted” after N images | imagecreatefromjpeg() resources or GD/Imagick objects never explicitly destroyed |
| 6 | “Memory leak” that’s actually a memory limit bug | A single request legitimately needs 600MB (huge CSV export) and dies at memory_limit=512M |
Not a leak at all — unbounded growth within one request, fixed by streaming instead of buffering |
Scenario 6 is worth calling out on its own: not every “PHP memory leak” is a leak. A leak is memory that should have been freed but wasn’t, across iterations. A single request loading a 2GB file into a string is just bad memory usage, not a leak — the fix is different (streaming/generators, not garbage collection).
The five usual suspects (with code)
1. Growing arrays / caches in a long-lived loop
// ❌ Runs inside `queue:work`, which never restarts the process.
// $processedIds grows forever and is never cleared.
class ImportListener
{
private array $processedIds = [];
public function handle(RowImported $event): void
{
$this->processedIds[] = $event->id; // never trimmed
}
}
// ✅ Don't accumulate state across jobs in a singleton/listener.
// Either scope it to the job, or cap and flush it explicitly.
class ImportListener
{
public function handle(RowImported $event): void
{
Cache::increment('import:processed'); // external store, not process memory
}
}
2. Loading everything instead of streaming
// ❌ Pulls all 2 million rows into one PHP array before the loop even starts.
foreach (User::all() as $user) {
$this->export->addRow($user);
}
// ✅ chunk() / cursor() keep memory flat regardless of table size.
User::query()->orderBy('id')->cursor()->each(function (User $user) {
$this->export->addRow($user);
});
cursor() uses a PHP generator and hydrates one model at a time; chunk(1000, ...) does it in batches. Either is a constant-memory fix for what would otherwise be O(n) memory growth in a single request or job.
3. Circular references that outlive their usefulness
// ❌ Closure captures $this, and $this holds an array of these closures.
// A cycle: Dispatcher -> listeners[] -> Closure -> $this (Dispatcher).
class EventBus
{
private array $listeners = [];
public function listen(callable $listener): void
{
$this->listeners[] = $listener;
}
}
$bus = new EventBus();
$bus->listen(fn ($e) => $bus->log($e)); // captures $bus by reference
This isn’t automatically catastrophic — PHP’s cycle collector will eventually free it — but on a hot path in a long-running worker, cycles pile up faster than the collector runs, and each collector pass itself costs CPU. Prefer weak references when a callback structurally needs to point back at its owner:
// ✅ WeakMap / WeakReference don't count toward refcount, so no cycle forms.
class EventBus
{
private array $listeners = [];
public function listen(object $owner, callable $listener): void
{
$this->listeners[] = new WeakReference($owner) === null ? $listener : $listener;
}
}
In practice, the simplest fix is usually structural: don’t let long-lived singletons hold closures that capture themselves — pass IDs or events, not $this.
4. Native resources that PHP’s GC doesn’t know about
// ❌ GD image resources are C-level memory. The zval can be garbage collected
// while the underlying bitmap is still sitting in memory in older PHP/extension versions,
// and even where it's tied correctly, forgetting imagedestroy() in a loop adds up.
foreach ($uploads as $path) {
$img = imagecreatefromjpeg($path);
$resized = imagescale($img, 800);
imagejpeg($resized, $outputPath);
// missing: imagedestroy($img); imagedestroy($resized);
}
// ✅ Explicitly free native resources every iteration in a long-running process.
foreach ($uploads as $path) {
$img = imagecreatefromjpeg($path);
$resized = imagescale($img, 800);
imagejpeg($resized, $outputPath);
imagedestroy($img);
imagedestroy($resized);
}
The same applies to Imagick objects (->clear(); ->destroy();), open file handles, and database cursors — anything backed by a C extension is a candidate for a leak PHP’s own GC cannot see, because it only tracks zvals, not the memory those extensions allocate underneath them.
5. Framework/global state that survives between requests (Octane, Swoole, RoadRunner)
// ❌ In a traditional PHP-FPM app this is harmless — the process dies after the request.
// Under Octane, this same singleton is reused for the NEXT request too.
class ReportBuilder
{
private array $rows = [];
public function addRow(array $row): void
{
$this->rows[] = $row; // never reset between requests under Octane
}
}
// ✅ Reset per-request state explicitly, or avoid singletons for request-scoped data.
class ReportBuilder
{
private array $rows = [];
public function boot(): void
{
$this->rows = []; // Octane's RequestReceived / RequestTerminated hooks call this
}
}
Laravel Octane specifically warns about this class of bug: anything bound as a singleton keeps its state across every request the worker handles, which is exactly the assumption that PHP-FPM code makes safely and Octane code cannot.
Debugging a real leak
// Minimal leak-hunting harness for a suspect long-running loop.
for ($i = 0; $i < 100000; $i++) {
processJob($jobs[$i]);
if ($i % 1000 === 0) {
gc_collect_cycles();
fwrite(STDERR, sprintf(
"[%d] mem=%.1fMB peak=%.1fMB\n",
$i,
memory_get_usage(true) / 1e6,
memory_get_peak_usage(true) / 1e6
));
}
}
If the mem= number keeps climbing even right after gc_collect_cycles(), it’s a real leak, not an uncollected cycle — force-running the collector rules that out.
Best practices checklist
| Practice | Why it matters |
|---|---|
Use cursor()/chunk() for big datasets |
Keeps memory flat regardless of row count; avoids “works on staging, dies on production” |
Never let queue:work run forever unmonitored |
Set --max-jobs, --max-time, or --memory=512 so Laravel recycles the worker itself before the OS has to kill it |
Explicitly free native resources (imagedestroy, $imagick->clear(), fclose) |
The GC doesn’t track memory a C extension allocated outside the zval system |
Avoid closures capturing $this in long-lived singletons |
Creates reference cycles that pile up faster than the collector runs on a hot path |
| Reset request-scoped singleton state under Octane/Swoole | Code that’s safe under PHP-FPM (new process per request) is not automatically safe under a persistent worker |
Unset large local variables after use in loops, e.g. unset($rows); gc_collect_cycles(); |
Nudges reclamation forward inside CPU-bound loops rather than waiting for the threshold-based collector |
Treat memory_limit errors as two different bugs |
“Grows every iteration” = leak (fix the accumulation). “One request needs more than the limit” = usage (fix with streaming, not GC) |
| Profile before guessing | Xdebug’s memory functions or Blackfire show the exact allocation site; guessing wastes more time than a five-minute profile |
A five-point summary
- PHP frees memory two ways: instantly via refcounting, and periodically via a cycle collector for circular references. Both matter only within one process’s lifetime.
- PHP-FPM requests don’t leak in practice because the whole process is thrown away after each request. Long-running processes (queue workers, Octane/Swoole, CLI scripts) are where leaks actually hurt.
- The usual suspects: unbounded caches/arrays in loops, loading whole datasets instead of streaming, closures creating reference cycles, un-freed native resources (GD/Imagick/file handles), and singleton state surviving between requests under Octane.
- Not every OOM is a leak. A single oversized request is a usage problem, fixed with generators/streaming — not with
gc_collect_cycles(). - Diagnose with data: log
memory_get_usage(true)every N iterations, forcegc_collect_cycles()before measuring, and bisect the loop until the climbing line stops climbing.
Conclusion
PHP’s garbage collector is good at its job — the leaks people hit in practice aren’t PHP failing to collect garbage, they’re code holding onto references longer than it needs to, inside a process that’s alive far longer than a single request ever used to be. Once you’re running queue workers or an Octane/Swoole app, you’ve opted into managing memory the way a long-running Java or Node service does: watch it, cap it, and free native resources yourself. Do that, and the sawtooth stays flat instead of climbing.