Finding and Preventing Memory Leaks in Long-Running PHP Processes
AI generated
<?php
8.4
PHP · Worker Processes · Memory Leaks · Monitoring
Memory Leaks in Long-Running PHP Processes
Finding, understanding and permanently preventing them

A classic PHP-FPM request automatically frees all its memory at the end, while a Swoole or RoadRunner worker lives on through thousands of requests within the same process. That exact difference turns static state, forgotten closures and growing caches into a real memory leak that slowly eats up available memory over hours of operation.

15 min read Swoole · RoadRunner · memory_get_usage PHP 8.x

1. Why long-running processes react differently to memory

In a classic PHP-FPM setup, every request starts in a fresh or at least largely reset execution context, and all requested memory is reclaimed by the operating system at the end of the request. A small memory leak that loses a few kilobytes per request practically never gets noticed in this model, because every new request starts back at zero.

With long-running processes using Swoole, RoadRunner, Amp or plain long-lived CLI commands, this picture changes fundamentally. The process stays alive across thousands or tens of thousands of requests, and every byte that never gets released stays permanently occupied. A memory leak of just a few kilobytes per request quickly adds up to several hundred megabytes over a day of operation, until the worker process either hits its configured memory limit and crashes, or the entire system becomes destabilized by memory pressure.

A memory leak in this context does not necessarily mean a classic bug like a lost pointer as in C, but almost always memory that PHP's reference counter considers correctly managed, yet that from a business logic perspective is never needed again while still being referenced. This distinction is exactly what makes debugging memory leaks in PHP different from languages with manual memory management.

2. Static properties and global state as the most common cause

By far the most common cause of a memory leak in long-running PHP processes is static class properties or global variables that grow uncontrolled over the process lifetime. A registry pattern, a static cache, or a logger that collects every log message in a static array instead of emitting it immediately never causes problems in a PHP-FPM request, because the request ends shortly after. In a worker process, however, this array keeps growing unbounded over the entire process lifetime.

Particularly insidious are static properties that appear to implement sensible caching, for example caching configuration values or translations per processed entity, without ever providing an upper bound or eviction strategy. What looks like a harmless optimization in a short-lived request becomes a steadily growing memory leak in the worker context across thousands of different entities, because the cache never shrinks.


declare(strict_types=1);

// LEAK: unbounded static cache grows for the entire worker lifetime
final class TranslationCache
{
    /** @var array<string, string> */
    private static array $cache = [];

    public static function translate(string $key, string $locale): string
    {
        $cacheKey = "{$locale}:{$key}";
        // Never evicted, grows with every unique key/locale combination
        return self::$cache[$cacheKey] ??= self::loadFromDatabase($key, $locale);
    }

    private static function loadFromDatabase(string $key, string $locale): string
    {
        return "translated:{$key}";
    }
}

// FIXED: bounded cache with explicit size limit and eviction
final class BoundedTranslationCache
{
    private const MAX_ENTRIES = 5_000;

    /** @var array<string, string> */
    private static array $cache = [];

    public static function translate(string $key, string $locale): string
    {
        $cacheKey = "{$locale}:{$key}";
        if (!isset(self::$cache[$cacheKey]) && count(self::$cache) >= self::MAX_ENTRIES) {
            array_shift(self::$cache); // evict oldest entry, keep bounded
        }
        return self::$cache[$cacheKey] ??= self::loadFromDatabase($key, $locale);
    }

    private static function loadFromDatabase(string $key, string $locale): string
    {
        return "translated:{$key}";
    }
}

3. Event listeners and closures that bind objects

A second common memory leak comes from event listeners or callback registrations that are never removed again. If a request-specific object registers itself as a listener on a long-lived event dispatcher without unsubscribing at the end of processing, the dispatcher holds a permanent, strong reference to that object through its listener list, even though it is no longer needed from a business logic perspective.

Closures aggravate this problem further, because they implicitly reference all variables captured via use, including any objects contained in them, even when no obvious object reference is visible in the code itself. A closure that accidentally captures $this from a request-specific class and gets handed to a long-lived dispatcher indirectly keeps the entire object alive, including all its properties, for as long as the closure itself remains referenced.


declare(strict_types=1);

final class EventDispatcher
{
    /** @var array<string, list<callable>> */
    private array $listeners = [];

    public function on(string $event, callable $listener): void
    {
        $this->listeners[$event][] = $listener;
    }

    // LEAK: no way to remove a listener once added, list grows forever
    // if request-scoped objects keep registering new listeners
}

// LEAK-prone pattern: request handler registers itself on every request
final class OrderProcessor
{
    public function __construct(private readonly EventDispatcher $dispatcher) {}

    public function process(): void
    {
        // Captures $this implicitly, keeps the whole OrderProcessor alive
        $this->dispatcher->on('order.completed', function () {
            $this->notifyCustomer();
        });
    }

    private function notifyCustomer(): void {}
}

// FIXED: explicit unsubscribe via a returned token, or use WeakReference
// inside the dispatcher's listener storage to avoid extending lifetime

4. Systematically measuring memory usage and spotting trends

memory_get_usage(true) returns the memory actually allocated by the operating system for the PHP process, memory_get_peak_usage(true) the highest value reached so far. For diagnosing memory leaks in workers, it is crucial to log these values not once but continuously across many requests, to get a trend instead of a single snapshot. Stable memory usage that reaches a plateau after an initial rise is unproblematic, a value that grows linearly or even exponentially across hundreds of requests is the clear signal of a real memory leak.

A simple but effective diagnostic approach: after every Nth request within a worker, log the current memory value and compute the difference from the previous measurement point. If this difference stays consistently close to zero across many measurement intervals, the worker operates stably. If the difference keeps growing instead, the point in time where the growth begins often already points to the triggering code path, especially when the growth correlates with certain request types.


declare(strict_types=1);

/**
 * Simple worker-level memory trend logger, call after every request.
 */
final class MemoryTrendLogger
{
    private int $lastUsage = 0;
    private int $requestCount = 0;

    public function recordRequest(): void
    {
        $this->requestCount++;
        $current = memory_get_usage(true);
        $delta = $current - $this->lastUsage;

        if ($this->requestCount % 100 === 0) {
            error_log(sprintf(
                '[memory] request=%d usage=%d delta_since_last_100=%d',
                $this->requestCount,
                $current,
                $delta
            ));
        }

        $this->lastUsage = $current;
    }
}

5. Worker recycling as a pragmatic safety net

Even with carefully written code, subtle memory leaks in complex applications, especially with many dependencies and third party libraries, cannot always be fully ruled out. That is why practically all production setups with Swoole or RoadRunner configure automatic worker recycling: after a fixed number of processed requests, or once a memory limit is exceeded, a worker process is cleanly terminated and replaced with a fresh one.

This recycling is not a substitute for fixing actual memory leaks, but a pragmatic safety net that prevents a previously undiscovered leak from crashing the entire process. It is important to choose the recycling interval so it catches the symptoms of a leak without itself becoming a performance drag through too frequent worker restarts, since every new worker start incurs initialization costs like rebuilding database connections.

6. Debugging with Xdebug, Blackfire and your own snapshots

For deeper analysis of a confirmed memory leak, Xdebug's xdebug_debug_zval() gives insight into the reference count of individual variables and helps clarify whether an object is actually still referenced from somewhere. For analyses closer to production, profilers like Blackfire are better suited, since they break down memory usage per function call without incurring the drastic performance overhead of a full Xdebug trace.

A practical do it yourself approach for hard to reproduce leaks: at several points in the code, spread over a longer period, take a snapshot with gc_collect_cycles() followed by memory_get_usage() and optionally the number of active objects per class through get_declared_classes() combined with reflection. Comparing several such snapshots over time frequently and reliably reveals which class accumulates an unusual number of instances, considerably narrowing the search space for the actual cause.

7. Frameworks and ORMs: typical leak sources

ORMs like Doctrine or Eloquent typically run an internal identity map or unit of work mechanism that caches loaded entities for the duration of a request to avoid loading the same entity multiple times. In a PHP-FPM request this cache is automatically discarded at the end of the request, in a worker process, however, the entity manager or corresponding unit of work must be explicitly reset after every processed request, otherwise this identity map grows unbounded with every newly loaded entity.

The same applies to dependency injection containers with incorrectly configured object scope: a service accidentally registered as a singleton instead of request scoped, but internally accumulating state across individual requests, for example a list of processed IDs for debugging purposes, becomes a classic memory leak in the worker context. The basic rule: any state that would be implicitly cleaned up by the end of a request in classic PHP-FPM must be explicitly reset in a worker process.

8. Preventive architecture patterns for long-running processes

The most effective preventive approach is to consistently encapsulate request-specific state in a freshly created object per request, rather than holding it in static properties or singletons that exist for the entire worker lifetime. Frameworks with explicit support for long-running processes often provide a reset hook for that purpose, resetting defined state after every request, regardless of whether the developer manually keeps track of every single spot in the code.

Additionally, it is worth deliberately using WeakMap for caches that should be reused across multiple requests but must not grow unbounded, along with a clear separation between process-wide state that should deliberately persist for the entire worker lifetime, such as a database connection, and request-specific state that must be fully discarded after every request.

9. Countermeasures compared directly

A direct comparison shows which countermeasure actually solves which problem.

Measure Fixes the cause? Effort Recommendation
Worker recycling No, symptom only Low, pure configuration Always enable as a safety net
Bounded static caches Yes Medium, code change needed Central prevention measure
WeakMap instead of array cache Yes Medium, targeted rebuild For object based caches
Reset hook after every request Yes Higher, framework dependent Essential for complex applications

The comparison shows: worker recycling belongs in every production setup but never replaces actually fixing the cause. Only measures that bound the uncontrolled growing state itself or deliberately reset it after every request are sustainably effective.

Mironsoft

Swoole and RoadRunner operations, memory analysis and worker architecture

Want to stop growing memory usage in your PHP workers?

We identify static state, forgotten listeners and ORM identity maps burdening your long-running PHP processes, and set up monitoring plus safe recycling strategies for stable continuous operation.

Leak diagnosis

Systematic measurement of memory_get_usage trends across many requests

Architecture refactoring

Rebuilding unbounded static state onto WeakMap and reset hooks

Worker operations configuration

Sensible recycling intervals and memory limits for Swoole and RoadRunner

10. Summary

Memory leaks in long-running PHP processes almost always arise from state that would be implicitly cleaned up at the end of a classic PHP-FPM request but must be explicitly reset in a worker process. Static caches without an upper bound, forgotten event listeners and closures that accidentally bind objects, and ORM identity maps without an explicit reset are by far the most common causes.

Systematic measurement with memory_get_usage() across many requests makes a growing memory leak visible before it leads to a process crash. Worker recycling belongs in every production setup as a pragmatic safety net, but never replaces actually fixing the cause through bounded caches, WeakMap based structures and consistent reset hooks after every request.

Memory Leaks in Long-Running PHP Processes, the Key Takeaways

Static state is the main cause

Unbounded static caches and global state grow without a limit with every request.

Measure trends, not snapshots

Log memory_get_usage() across many requests to distinguish real leaks from normal growth.

Worker recycling as a safety net

Automatic restart after N requests catches undiscovered leaks but does not replace fixing the cause.

Explicitly reset request state

What PHP-FPM cleans up implicitly must be actively released in workers via reset hooks or WeakMap.

11. FAQ: Memory Leaks in Long-Running PHP Processes

1What is a memory leak in a worker?
Memory that stays referenced despite no longer being needed, adds up across many requests.
2Why rarely with PHP-FPM?
Every request frees its memory automatically, small leaks never get noticed.
3Most common cause?
Static properties or global state without an upper bound that grow over the process lifetime.
4How do closures cause leaks?
They implicitly capture $this, bound to a long-lived dispatcher they keep the whole object alive.
5How do I measure a leak?
Log memory_get_usage() across many requests, a linear rise is the signal.
6Does worker recycling solve it?
No, only a safety net, does not replace fixing the cause in the code.
7Why reset ORM in workers?
The identity map is not cleared automatically like in PHP-FPM, otherwise it grows unbounded.
8Does WeakMap fix all leaks?
Only object caches, not scalar arrays or reference cycles.
9Which tools for production?
Blackfire and custom snapshot comparisons with memory_get_usage() without Xdebug overhead.
10Growth vs. real leak?
A plateau after an initial rise is normal, continuous growth without a plateau is a leak.