Tracing & Performance

Debugging PHP Memory Leaks in Long-Running Processes

PHP-FPM hides leaks; workers and queue consumers don't. Learn to spot growth with memory_get_usage, find cycles and static caches, and fix the real leak.

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 processesLaravel 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.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.