A PHP memory leak in a long-running process is an invisible killer. Your application works perfectly in development, but a queue worker or cron job grows and grows until it finally crashes with "Allowed memory size exhausted" after running for hours. The culprit is not actually a leak in the traditional sense—it's usually objects or data that aren't freed because PHP's garbage collector doesn't know they can be released. This guide walks you through diagnosing and fixing PHP memory leaks before they take down your production workers.
The key insight: PHP-FPM hides leaks. In a traditional PHP web application, every request gets its own process (or reuses a process for a brief period), and memory is reclaimed after the request finishes. A leak that grows 10 KB per request is invisible because the process dies. But in long-running processes—Laravel Horizon workers, Symfony Messenger jobs, CLI commands, cron jobs, or persistent runtimes like Swoole and RoadRunner—that 10 KB compounds into a catastrophe. That's why your queue worker balloons to 512 MB while your web app stays lean.
How PHP's memory model enables leaks
PHP uses reference counting to free memory: when an object has zero references, it's deallocated immediately. But reference counting fails at one crucial task: breaking reference cycles. When object A holds a reference to object B, and B holds a reference back to A, neither object's refcount ever drops to zero, so neither is freed—even if your code no longer needs them.
<?php
class Node {
public ?Node $next = null;
public function __destruct() {
echo "Node destroyed\n";
}
}
$node1 = new Node();
$node2 = new Node();
$node1->next = $node2;
$node2->next = $node1; // cycle: $node1 ↔ $node2
unset($node1);
unset($node2);
// Neither __destruct is called—they leak!
PHP has a cycle collector that runs periodically (or on demand via gc_collect_cycles()) to break these cycles, but it only runs when its internal buffer fills—typically after thousands of allocations. In a queue worker processing thousands of jobs, cycles can accumulate faster than the collector runs.
Enable garbage collection explicitly with gc_enable() or call gc_collect_cycles() after processing each job to free cycles immediately. Most Laravel and Symfony queue workers already do this, but CLI commands often don't.
The real causes of memory leaks in workers
Static properties and singletons
The most common real leak: an array on a static property or singleton that grows every iteration.
<?php
class RequestCache {
private static array $cache = [];
public static function store($key, $value) {
self::$cache[$key] = $value; // grows forever
}
}
// In a queue worker job loop:
foreach ($jobs as $job) {
RequestCache::store('result_' . $job->id, $expensiveResult);
// Static cache never empties—leaks 1+ MB per job
}
Fix: clear the static cache or use an instance (scoped to the request) instead of a static property.
Event listeners and container bindings
Frameworks like Laravel and Symfony register listeners and bindings that can accumulate without cleanup.
<?php
// Bad: listener registered per job iteration
foreach ($records as $record) {
Event::listen('order.created', function ($order) {
logger()->info('Order: ' . $order->id);
});
// Listener is added to a global registry; never removed
}
Fix: register listeners once at startup, not per job.
Loading entire result sets
The second-most common leak: fetching all rows from a large table into memory at once. Laravel's all() and Doctrine's identity map are notorious culprits.
<?php
// Bad: loads all 500k users at once
$users = User::all();
foreach ($users as $user) {
processUser($user);
}
// Good: chunk it
User::chunk(1000, function ($chunk) {
foreach ($chunk as $user) {
processUser($user);
}
});
// Also good: use lazy collection (Laravel 6+)
User::lazy()->each(fn ($user) => processUser($user));
Doctrine users: the identity map—the ORM's internal cache of loaded entities—grows as you load objects. Call $em->clear() after processing a batch:
<?php
foreach ($ids as $id) {
$entity = $em->find(MyEntity::class, $id);
processEntity($entity);
$em->clear(); // flush the identity map
}
Unbounded caches, logs, and buffers
In-memory caches, logs, or buffers that grow without expiry or a size limit.
<?php
// Bad: logs accumulate in memory
$logs = [];
for ($i = 0; $i < 1000000; $i++) {
$logs[] = "Iteration $i completed";
}
// $logs now consumes 50+ MB
// Good: write to disk or flush periodically
$fp = fopen('job.log', 'a');
for ($i = 0; $i < 1000000; $i++) {
fwrite($fp, "Iteration $i completed\n");
if ($i % 10000 == 0) {
fflush($fp); // write to disk
}
}
fclose($fp);
Closures capturing $this
Closures that capture $this (explicitly or implicitly) can keep large objects alive longer than intended.
<?php
class Worker {
private array $data = []; // large dataset
public function process() {
// Closure captures $this implicitly
$callback = fn() => array_map(fn($x) => $x * 2, $this->data);
// $this stays in memory as long as $callback exists
}
}
Fix: extract only the data you need:
$data = $this->data;
$callback = fn() => array_map(fn($x) => $x * 2, $data);
Extension leaks (rare)
Occasionally, a PHP extension (Redis, database driver, XML parser) leaks memory. This is the last suspect, not the first—always profile your application code first.
Measuring memory leaks
A leak is growth over time, not high usage. A single request using 50 MB is fine; a process growing from 50 MB to 200 MB over 1000 requests is a leak.
Use memory_get_usage() to track this:
<?php
$memBefore = memory_get_usage(true); // true = real (emalloc'd) memory
processJob();
$memAfter = memory_get_usage(true);
$delta = ($memAfter - $memBefore) / 1024 / 1024;
error_log("Job consumed {$delta}MB");
The true parameter returns actual OS-level memory (emalloc blocks), not PHP's internal tracking. Log the delta per job; if it averages 1 MB per job over 100 jobs, you have a leak.
For a longer-running process, track peak usage:
<?php
// At startup
$initialPeak = memory_get_peak_usage(true);
// After each batch
$currentPeak = memory_get_peak_usage(true);
if ($currentPeak > $initialPeak) {
$growth = ($currentPeak - $initialPeak) / 1024 / 1024;
error_log("Peak grown by {$growth}MB since start");
}
Log memory stats to your error tracker following error-tracking best practices so you can see trends across runs. A graph of peak memory over time reveals the leak's slope—critical for knowing whether your fix actually worked.
Tools for profiling
Xdebug memory profiling (development): Enable xdebug.mode = profile and analyze the cachegrind file with KCacheGrind. It shows memory allocations per function.
php-meminfo: A Composer package that dumps object counts and memory usage at a given point:
composer require BitOne/php-meminfo
Then use it in your job:
<?php
\BitOne\PhpMeminfo\Dumper::dump(fopen('php://stderr', 'w'));
Pragmatic approach: Set a memory limit or job limit on your workers. Laravel's queue runner accepts --max-jobs and --memory:
php artisan queue:work --max-jobs=100 --memory=256
After 100 jobs or when memory hits 256 MB, the worker restarts. This is not a fix—it's damage control—but it's standard practice. A bounding strategy keeps your application reliable while you hunt the leak.
For dead-letter-queue monitoring, this approach catches jobs that fail due to memory exhaustion before restarting, so you don't lose work.
Tie it to error tracking
A long-running process that leaks memory eventually crashes with "Allowed memory size exhausted." Link your Sentry SDK to LightTrace:
<?php
Sentry\init([
'dsn' => 'https://<key>@light-trace.robomiri.com/1',
]);
LightTrace captures the fatal error with the job context (queue name, job ID, attempt count), so you see which jobs tend to leak:
<?php
Sentry\setTag('queue', 'emails');
Sentry\setTag('job_id', $job->id);
Sentry\setTag('attempt', $attempts);
processJob($job);
When a worker crashes with memory exhaustion, LightTrace groups the errors and alerts you. Correlate the errors with your memory logs to isolate the leak.
Start tracking errors in minutes
Try LightTrace free to capture memory-exhaustion errors from your queue workers and long-running processes with full job context, so you can pinpoint which jobs are leaking before they take down your worker fleet.
Memory leaks in long-running PHP processes are fixable. Start by measuring: log memory deltas per job and spot the upward trend. Profile with Xdebug or php-meminfo to find which objects are holding references. Fix the common culprits (static caches, full result sets, event listener bloat). And always set a worker restart policy to bound the blast radius while you fix the root cause.