Senior 6 min · March 06, 2026

PHP Static Cache Memory Leak — Worker OOM

Worker RSS grew from 50 MB to 500 MB in 12 hours - no error logs.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • PHP stores every value in a zval — compact union; scalars live on stack in PHP 7+.
  • Reference counting frees memory instantly when refcount hits zero; Copy-on-Write avoids copying until mutation.
  • Circular references (A->B->A) defeat refcounting — cyclic GC marks and sweeps them.
  • Static caches and global arrays are the #1 cause of unbounded growth in CLI workers.
  • Use gc_status() + memory_get_usage(true) across iterations — monotonic growth = leak.
Plain-English First

Think of PHP's memory like a whiteboard in a busy office. Every time someone writes a variable on it, they claim a small section. When they're done, they erase it so someone else can use that space. The tricky part is when two people point to the same note on the board — PHP has to track how many people are still looking at it before it's safe to erase. If tracking breaks down and nobody erases old notes, the whiteboard fills up and the office grinds to a halt. That's a memory leak.

Memory management isn't glamorous — until your production server starts throwing 'Allowed memory size exhausted' errors at 2 AM and your on-call phone won't stop buzzing. PHP abstracts most of memory handling away from you, which is wonderful for productivity and dangerous for performance engineering. The moment you start building long-running workers, processing large CSV imports, or handling thousands of concurrent requests under FPM, the hidden mechanics of how PHP allocates, tracks, and frees memory become the difference between a stable service and a ticking time bomb.

The core problem PHP memory management solves is automatic resource reclamation — so you don't have to manually free every string, array, and object like you would in C. PHP uses a hybrid strategy: reference counting for the fast path and a cyclic garbage collector for the edge cases that reference counting can't handle on its own. Both mechanisms have real costs and real failure modes that surface in production code all the time.

By the end of this article you'll understand how PHP's zval structure stores every value in memory, exactly how the reference count rises and falls, why circular references defeat the simple counter, how the mark-and-sweep cycle collector rescues you, and — most importantly — which coding patterns silently bleed memory in long-running scripts so you can detect and fix them before they hit production.

How PHP Stores Every Value: The zval Internals

Every single value in PHP — a string, an integer, an array, an object — lives inside a structure called a zval (Zend Value). In PHP 7+ this structure was completely redesigned to be far more cache-friendly, dropping from a heap-allocated pointer maze to a compact, stack-friendly union.

A modern zval holds three things: a type tag (one byte that says 'this is a string' or 'this is an object'), a value union (the actual data, or a pointer to it for heap types), and a reference count embedded inside the pointed-to structure itself rather than in the zval. This is a critical PHP 7 optimisation — small integers and certain booleans are stored directly in the zval with no heap allocation at all, making them essentially free to copy.

For heap types (strings, arrays, objects) PHP allocates a separate structure on the Zend Memory Manager's heap. That structure carries a refcount field and a type_info bitfield. The type_info encodes whether the value is reference-counted at all, whether it's immutable (interned strings), and whether it may contain cycles. Understanding this lets you reason about when PHP actually allocates memory versus when it just copies a tiny stack value.

ZvalInspection.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
declare(strict_types=1);

/**
 * Demonstrates how PHP handles zval internals through observable behaviour.
 * We can't inspect raw zvals from userland, but xdebug_debug_zval() exposes
 * the refcount and is_ref flag — the two most important zval properties.
 *
 * Run with: php -d extension=xdebug.so ZvalInspection.php
 * (xdebug must be installed; the output below is what you'll see)
 */

// --- Integer: no heap allocation in PHP 7+ ---
$temperature = 42;
// Integers fit inside the zval union directly — no malloc, refcount=1 logically
echo "=== Integer (stack value) ===\n";
xdebug_debug_zval('temperature');  // refcount=1, is_ref=false

// --- String: heap-allocated, refcount starts at 1 ---
$cityName = 'London';
echo "\n=== String after first assignment ===\n";
xdebug_debug_zval('cityName');     // refcount=1, is_ref=false

// --- Copy-on-Write: assigning to a new variable does NOT copy the string yet ---
$copiedCity = $cityName;
echo "\n=== After $copiedCity = $cityName (CoW — same string, refcount bumped) ===\n";
xdebug_debug_zval('cityName');     // refcount=2, is_ref=false — SHARED, not copied!

// --- Mutation triggers the actual copy ---
$copiedCity = strtoupper($copiedCity); // 'LONDON' — a new string is created here
echo "\n=== After mutating copiedCity (CoW copy triggered) ===\n";
xdebug_debug_zval('cityName');     // refcount=1, is_ref=false — back to exclusive
xdebug_debug_zval('copiedCity');   // refcount=1, is_ref=false — new string

// --- Reference (&): forces is_ref=true, bypasses CoW ---
$originalScore = 100;
$scoreAlias = &$originalScore;    // now BOTH point to a zend_reference wrapper
echo "\n=== After creating a reference ===\n";
xdebug_debug_zval('originalScore'); // refcount=2, is_ref=true

echo "\noriginalScore: $originalScore
Output
=== Integer (stack value) ===
temperature: (refcount=0, is_ref=0)=42
=== String after first assignment ===
cityName: (refcount=1, is_ref=0)='London'
=== After $copiedCity = $cityName (CoW — same string, refcount bumped) ===
cityName: (refcount=2, is_ref=0)='London'
=== After mutating copiedCity (CoW copy triggered) ===
cityName: (refcount=1, is_ref=0)='London'
copiedCity: (refcount=1, is_ref=0)='LONDON'
=== After creating a reference ===
originalScore: (refcount=2, is_ref=1)=100
originalScore: 100
Why refcount=0 for integers?
In PHP 7+, integers, floats, booleans, and null are 'non-refcounted' types — they live directly in the zval union and are always copied by value. xdebug reports refcount=0 to indicate 'this type doesn't use reference counting'. It's not a bug — it's a deliberate optimisation that eliminates heap traffic for scalar hotpaths.
Production Insight
xdebug_debug_zval() is a debug-only tool — never run in production.
Use memory_get_usage() + gc_status() for production telemetry instead.
Rule: zval internals explain allocation patterns — not every allocation is a malloc.
Key Takeaway
Scalars (int, float, bool, null) live inside the zval — zero heap allocations.
Heap types (string, array, object) carry a refcount in a separate structure.
PHP 7+ zval redesign reduced memory overhead by ~40% — but only for scalars.

Reference Counting and Copy-on-Write: The Engine's Fast Path

Reference counting is PHP's primary memory reclamation strategy. Every heap-allocated value carries a refcount. When you assign a variable, pass it to a function, or store it in an array, the count goes up. When a variable goes out of scope, is unset, or is reassigned, the count goes down. When it hits zero, PHP immediately frees the memory — no garbage collection pause required.

Copy-on-Write (CoW) is the companion optimisation that makes reference counting cheap. When you write $b = $a, PHP doesn't copy the actual data — it just bumps the refcount and lets both variables share the same memory. Only when one of them tries to modify the value does PHP perform the actual copy. This means passing a 50MB string to a function costs almost nothing if the function only reads it.

But there's a subtle trap: passing by reference (&) opts you out of CoW. PHP wraps the value in a zend_reference container so both sides always see mutations. This sounds convenient but it can force copies at unexpected moments in other parts of the code that were happily sharing the original value. Profiling often reveals that overuse of & in hot loops actually increases memory pressure, not decreases it.

CopyOnWrite.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php
declare(strict_types=1);

/**
 * Demonstrates Copy-on-Write (CoW) memory behaviour with a large dataset.
 * Shows how PHP avoids copying until it absolutely must, and what breaks CoW.
 */

function getMemoryUsageMB(): float
{
    return round(memory_get_usage(true) / 1024 / 1024, 2);
}

// --- Build a large array (simulates loading records from DB) ---
$productCatalogue = [];
for ($i = 0; $i < 100_000; $i++) {
    $productCatalogue[] = [
        'id'    => $i,
        'name'  => 'Product ' . $i,
        'price' => mt_rand(100, 9999) / 100,
    ];
}

$beforeCopy = getMemoryUsageMB();
echo "Memory after building catalogue: {$beforeCopy} MB\n";

// --- CoW: assigning to a new variable does NOT duplicate the array in memory ---
$catalogueSnapshot = $productCatalogue;  // refcount bumped to 2 — no malloc
$afterCoWAssign = getMemoryUsageMB();
echo "Memory after CoW assign (expect near zero increase): {$afterCoWAssign} MB\n";
echo "Increase: " . ($afterCoWAssign - $beforeCopy) . " MB\n";

// --- Triggering a real copy: mutating the snapshot separates the two arrays ---
$catalogueSnapshot[0]['price'] = 0.01;  // 'SALE!' — this forces the full array copy
$afterMutation = getMemoryUsageMB();
echo "\nMemory after mutating snapshot (full copy created): {$afterMutation} MB\n";
echo "Increase: " . ($afterMutation - $beforeCopy) . " MB\n";

// --- Passing to a read-only function: still uses CoW, no copy ---
function countExpensiveProducts(array $catalogue, float $threshold): int
{
    // We only READ the array — CoW means no copy happened when $catalogue was received
    return count(array_filter(
        $catalogue,
        fn(array $product): bool => $product['price'] > $threshold
    ));
}

$expensiveCount = countExpensiveProducts($productCatalogue, 50.00);
$afterReadOnlyCall = getMemoryUsageMB();
echo "\nExpensive products over $50: {$expensiveCount}\n";
echo "Memory after read-only function call: {$afterReadOnlyCall} MB (CoW — no spike)\n";

// --- Danger: unintentionally breaking CoW with & in foreach ---
echo "\n--- CoW-breaking pattern: foreach with reference ---\n";
$beforeRef = getMemoryUsageMB();
foreach ($productCatalogue as &$product) {  // & forces a write-separation for every element!
    // Even if we never mutate $product, this still breaks CoW on the iterated array
    // because PHP can't know in advance whether we will write.
}
unset($product); // ALWAYS unset the dangling reference after a reference-foreach!
$afterRef = getMemoryUsageMB();
echo "Memory with reference foreach: {$afterRef} MB\n";
echo "Lesson: avoid foreach-by-reference unless you genuinely need to mutate in place.\n";
Output
Memory after building catalogue: 38.00 MB
Memory after CoW assign (expect near zero increase): 38.00 MB
Increase: 0 MB
Memory after mutating snapshot (full copy created): 76.00 MB
Increase: 38.00 MB
Expensive products over $50: 33421
Memory after read-only function call: 76.00 MB (CoW — no spike)
--- CoW-breaking pattern: foreach with reference ---
Memory with reference foreach: 76.00 MB
Lesson: avoid foreach-by-reference unless you genuinely need to mutate in place.
Watch Out: The Dangling Reference Trap
After foreach ($array as &$item), the variable $item remains a reference to the LAST element of the array after the loop ends. If you then use $item for anything else in the same scope, you'll silently corrupt the last element of your array. Always add unset($item) immediately after any reference-foreach. This is one of the most common, hardest-to-spot bugs in PHP codebases.
Production Insight
Overusing & in loops breaks CoW — memory doubles despite no visible mutation.
Profiling often shows & actually increases memory pressure in hot paths.
Rule: use & only when you intend to mutate the original inside the loop.
Key Takeaway
CoW makes read-only variable sharing near-zero cost — no copy until write.
Reference (&) opts out of CoW — forces shared mutable state with performance cost.
Always unset($item) after foreach-by-reference to avoid dangling reference corruption.

Cyclic Garbage Collection: When Reference Counting Isn't Enough

Reference counting has one fundamental blind spot: circular references. If object A holds a reference to object B, and object B holds a reference back to object A, both refcounts stay at 1 forever — even after all external references are gone. Neither object ever reaches zero, so neither is ever freed. Over a long-running process, this is a slow death by a thousand leaks.

PHP 5.3 introduced a cyclic garbage collector to plug this hole. It's inspired by the Bacon-Rajan algorithm and works in two phases. First, PHP maintains a root buffer — whenever a refcount decreases (but doesn't hit zero), the value is added as a potential cycle root. When the buffer fills up (default: 10,000 roots), or when you call gc_collect_cycles(), the collector runs.

The collection algorithm does a depth-first traversal, tentatively decrementing every refcount it can reach from each root. Any node that still has a refcount above zero after this traversal is reachable from outside the cycle and is safe — its counts are restored. Anything that reaches zero is garbage and gets freed. This is a stop-the-world pause, which matters in latency-sensitive code.

CyclicGarbageCollection.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
<?php
declare(strict_types=1);

/**
 * Demonstrates circular reference memory leaks and how PHP's cyclic
 * garbage collector rescues us — plus the WeakReference escape hatch.
 */

function getMemoryKB(): int
{
    return (int)(memory_get_usage() / 1024);
}

// --- 1. THE LEAK: two objects referencing each other ---
class OrderItem
{
    public string $productName;
    public ?Order $parentOrder = null; // back-reference — the cycle-forming link

    public function __construct(string $productName)
    {
        $this->productName = $productName;
    }
}

class Order
{
    public int $orderId;
    /** @var OrderItem[] */
    public array $items = [];

    public function __construct(int $orderId)
    {
        $this->orderId = $orderId;
    }

    public function addItem(OrderItem $item): void
    {
        $item->parentOrder = $this;  // CYCLE: $item -> $order -> $items[n] -> $item
        $this->items[] = $item;
    }
}

// Disable GC so we can see the raw leak in isolation
gc_disable();

$memBefore = getMemoryKB();

for ($iteration = 0; $iteration < 5_000; $iteration++) {
    $order = new Order($iteration);
    $order->addItem(new OrderItem('Widget A'));
    $order->addItem(new OrderItem('Widget B'));
    // $order goes out of scope here — but because of the cycle,
    // refcounts never reach 0, so NOTHING is freed.
    unset($order);
}

$memAfterLeak = getMemoryKB();
echo "=== Cyclic Leak (GC disabled) ===\n";
echo "Memory before loop: {$memBefore} KB\n";
echo "Memory after 5000 leaked cycles: {$memAfterLeak} KB\n";
echo "Leaked: " . ($memAfterLeak - $memBefore) . " KB\n";

// --- 2. THE RESCUE: run the cyclic garbage collector manually ---
$cyclesCollected = gc_collect_cycles(); // walks root buffer, frees unreachable cycles
$memAfterGC = getMemoryKB();
echo "\nCycles collected: {$cyclesCollected}\n";
echo "Memory after gc_collect_cycles(): {$memAfterGC} KB\n";
echo "Freed by GC: " . ($memAfterLeak - $memAfterGC) . " KB\n";

gc_enable(); // restore normal operation

// --- 3. THE BETTER FIX: WeakReference breaks the cycle structurally ---
class OrderV2
{
    public int $orderId;
    /** @var OrderItemV2[] */
    public array $items = [];

    public function __construct(int $orderId)
    {
        $this->orderId = $orderId;
    }

    public function addItem(OrderItemV2 $item): void
    {
        // WeakReference does NOT increment the refcount of the Order object
        // so the cycle is broken — when external refs drop, Order is freed immediately
        $item->parentOrderRef = WeakReference::create($this);
        $this->items[] = $item;
    }
}

class OrderItemV2
{
    public string $productName;
    public ?WeakReference $parentOrderRef = null; // does NOT hold a strong ref

    public function __construct(string $productName)
    {
        $this->productName = $productName;
    }

    public function getParentOrderId(): ?int
    {
        $parentOrder = $this->parentOrderRef?->get(); // returns null if Order was GC'd
        return $parentOrder?->orderId;
    }
}

$memBeforeV2 = getMemoryKB();
for ($iteration = 0; $iteration < 5_000; $iteration++) {
    $order = new OrderV2($iteration);
    $order->addItem(new OrderItemV2('Widget A'));
    $order->addItem(new OrderItemV2('Widget B'));
    unset($order); // Order's refcount hits 0 immediately — freed without GC!
}
$memAfterV2 = getMemoryKB();
echo "\n=== WeakReference fix ===\n";
echo "Memory before loop: {$memBeforeV2} KB\n";
echo "Memory after 5000 iterations (no leak): {$memAfterV2} KB\n";
echo "Net change: " . ($memAfterV2 - $memBeforeV2) . " KB (should be near 0)\n";
Output
=== Cyclic Leak (GC disabled) ===
Memory before loop: 512 KB
Memory after 5000 leaked cycles: 3847 KB
Leaked: 3335 KB
Cycles collected: 15000
Memory after gc_collect_cycles(): 534 KB
Freed by GC: 3313 KB
=== WeakReference fix ===
Memory before loop: 534 KB
Memory after 5000 iterations (no leak): 541 KB
Net change: 7 KB (should be near 0)
Pro Tip: gc_collect_cycles() in Long-Running Workers
In PHP CLI workers (queue consumers, import scripts), call gc_collect_cycles() at the end of each job iteration rather than relying on the root buffer threshold. This gives you predictable, controlled GC pauses instead of random stop-the-world stutters. Pair it with gc_status() (PHP 8.0+) to log cycle collection stats and alert if freed > 0 unexpectedly — that's your early warning system for new circular reference bugs.
Production Insight
Cyclic GC adds a stop-the-world pause — O(graph size) — which can spike latency.
WeakReference prevents cycles at design time, avoiding GC pauses entirely.
Rule: prefer structural cycle prevention over relying on the GC to clean up.
Key Takeaway
Reference counting fails on circular references — refcounts never hit 0.
Cyclic GC rescues them with mark-and-sweep, but at a latency cost.
Use WeakReference for parent-child relationships — break the cycle, avoid the pause.

Production Memory Profiling and Leak Detection Patterns

Knowing the theory is worthless if you can't apply it when your staging environment is eating 500MB per worker per hour. Production memory debugging requires a layered toolkit: coarse-grained memory_get_usage() checkpoints, mid-level gc_status() telemetry, and fine-grained profiling with tools like Blackfire or Xdebug's memory profiler.

The most practical first step is memory bracketing: record memory before and after each logical unit of work (one queue job, one import row batch, one API request in a long-lived Swoole context). If memory grows monotonically across iterations, you have a leak. If it spikes and returns, you just have a large temporary allocation — totally normal.

The second step is identifying what is leaking. gc_status() tells you how many roots are pending, how many cycles were collected, and how much GC ran. If you see root buffer fills happening every few iterations, you have a circular reference problem. If memory grows but gc_status() shows zero cycles collected, you likely have a growing static/global collection, an event listener accumulating closures, or a cache that never evicts.

For deep profiling, Blackfire's memory dimension shows allocation counts per function call. A function that allocates 10MB across 1 million tiny allocations is a very different problem to one that allocates one 10MB blob — and the fix for each is completely different.

MemoryLeakDetector.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<?php
declare(strict_types=1);

/**
 * A practical memory monitoring harness for long-running PHP workers.
 * Demonstrates: memory bracketing, gc_status() telemetry, and
 * static cache leak simulation with detection.
 *
 * Pattern: wrap every job iteration with MemoryMonitor to catch leaks early.
 */

final class MemoryMonitor
{\n    private int $baselineBytes;\n    private int $iterationCount = 0;\n    private int $totalBytesLeaked = 0;\n\n    public function __construct(\n        private readonly int  $alertThresholdBytes = 1_048_576, // 1 MB per iteration\n        private readonly bool $forceGcBeforeMeasure = true\n    ) {\n        $this->baselineBytes = memory_get_usage();\n    }

    public function beginIteration(): void
    {
        $this->iterationCount++;
        // Ensure we're measuring steady-state, not leftover allocation noise
        if ($this->forceGcBeforeMeasure) {
            gc_collect_cycles();
        }
        $this->baselineBytes = memory_get_usage();
    }

    public function endIteration(string $jobDescription): void
    {
        if ($this->forceGcBeforeMeasure) {
            gc_collect_cycles();
        }
        $currentBytes  = memory_get_usage();
        $deltaBytes    = $currentBytes - $this->baselineBytes;
        $this->totalBytesLeaked += max(0, $deltaBytes); // only count growth, not shrink

        $gcStatus = gc_status();

        $report = sprintf(
            '[Iter %04d] %-30s | Delta: %+d KB | Peak: %d MB | GC Roots: %d | Cycles Collected: %d',
            $this->iterationCount,
            $jobDescription,
            (int)($deltaBytes / 1024),
            (int)(memory_get_peak_usage() / 1024 / 1024),
            $gcStatus['roots'],        // pending roots in the buffer
            $gcStatus['collected'],    // total cycles freed since process start
        );

        echo $report . "\n";

        // Alert if this single iteration leaked more than the threshold
        if ($deltaBytes > $this->alertThresholdBytes) {
            echo "  *** MEMORY ALERT: iteration leaked " . (int)($deltaBytes / 1024) . " KB — investigate immediately ***\n";
        }
    }

    public function summary(): void
    {
        echo "\n=== Memory Monitor Summary ===\n";
        echo "Total iterations:   {$this->iterationCount}\n";
        echo "Total net growth:   " . (int)($this->totalBytesLeaked / 1024) . " KB\n";
        echo "Per-iteration avg:  " . (int)($this->totalBytesLeaked / $this->iterationCount / 1024) . " KB\n";
        echo "Current usage:      " . (int)(memory_get_usage() / 1024) . " KB\n";
        echo "Peak usage:         " . (int)(memory_get_peak_usage() / 1024 / 1024) . " MB\n";
    }
}

// --- Simulate two job types: a clean one and a leaking one ---

// LEAK PATTERN: a static cache that never evicts (common in poorly-designed singletons)
class ProductRepository
{
    /** @var array<int, array<string,mixed>> */
    private static array $queryCache = []; // grows forever — classic long-running leak

    public static function findById(int $productId): array
    {
        if (!isset(self::$queryCache[$productId])) {
            // Simulates a DB row — in reality this would be a PDO fetch
            self::$queryCache[$productId] = [
                'id'          => $productId,
                'name'        => 'Product ' . $productId,
                'description' => str_repeat('x', 1024), // 1KB per entry
            ];
        }
        return self::$queryCache[$productId];
    }

    // The fix: add a cache size limit or a clear method called after each job
    public static function clearCache(): void
    {
        self::$queryCache = [];
    }
}

function processOrderJobClean(int $orderId): void
{
    // Fetches product but clears the static cache after each job
    ProductRepository::findById($orderId);
    ProductRepository::findById($orderId + 1);
    ProductRepository::clearCache(); // releases static cache — memory returns to baseline
}

function processOrderJobLeaking(int $orderId): void
{
    // Fetches product but NEVER clears the cache — static grows unbounded
    ProductRepository::findById($orderId);
    ProductRepository::findById($orderId + 1);
    // No clearCache() call — each iteration permanently adds ~2KB to the static array
}

$monitor = new MemoryMonitor(alertThresholdBytes: 4096); // alert on > 4KB growth per iter

echo "--- Phase 1: Clean jobs (cache cleared each iteration) ---\n";
for ($jobId = 1; $jobId <= 5; $jobId++) {
    $monitor->beginIteration();
    processOrderJobClean($jobId * 100);
    $monitor->endIteration("processOrderJob#" . $jobId);
}

echo "\n--- Phase 2: Leaking jobs (static cache grows unbounded) ---\n";
for ($jobId = 1; $jobId <= 5; $jobId++) {
    $monitor->beginIteration();
    processOrderJobLeaking($jobId * 100 + 50);
    $monitor->endIteration("leakingJob#" . $jobId);
}

$monitor->summary();
Output
--- Phase 1: Clean jobs (cache cleared each iteration) ---
[Iter 0001] processOrderJob#1 | Delta: +0 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
[Iter 0002] processOrderJob#2 | Delta: +0 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
[Iter 0003] processOrderJob#3 | Delta: +0 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
[Iter 0004] processOrderJob#4 | Delta: +0 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
[Iter 0005] processOrderJob#5 | Delta: +0 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
--- Phase 2: Leaking jobs (static cache grows unbounded) ---
[Iter 0006] leakingJob#1 | Delta: +2 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
*** MEMORY ALERT: iteration leaked 2 KB — investigate immediately ***
[Iter 0007] leakingJob#2 | Delta: +2 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
*** MEMORY ALERT: iteration leaked 2 KB — investigate immediately ***
[Iter 0008] leakingJob#3 | Delta: +2 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
*** MEMORY ALERT: iteration leaked 2 KB — investigate immediately ***
[Iter 0009] leakingJob#4 | Delta: +2 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
*** MEMORY ALERT: iteration leaked 2 KB — investigate immediately ***
[Iter 0010] leakingJob#5 | Delta: +2 KB | Peak: 2 MB | GC Roots: 0 | Cycles Collected: 0
*** MEMORY ALERT: iteration leaked 2 KB — investigate immediately ***
=== Memory Monitor Summary ===
Total iterations: 10
Total net growth: 10 KB
Per-iteration avg: 1 KB
Current usage: 643 KB
Peak usage: 2 MB
Watch Out: Static Properties in Long-Running PHP
Static properties and static local variables are process-level state. Under PHP-FPM each worker process resets between requests, so statics are safe there. But in CLI workers (Laravel Queue workers, Symfony Messenger consumers, ReactPHP event loops, Swoole servers) statics persist for the entire process lifetime. A static cache without an eviction strategy will grow until the worker is killed. Audit every static $var and every self::$property in code that runs inside a long-lived PHP process.
Production Insight
Static leaks don't trigger GC — gc_status() shows no cycles, yet memory grows.
Memory bracketing at job boundaries catches static leaks before OOM.
Rule: monitor memory_get_usage() delta per iteration and alert on >5% growth.
Key Takeaway
Memory bracketing (start/end per job) separates leaks from temporary spikes.
gc_status() with zero collected + growing memory = static accumulation.
Blackfire alloc profiling tells you which function — and how many allocations — cause the leak.

PHP Memory Configuration and Tuning: memory_limit, Real Usage, and Fragmentation

Beyond the algorithmic side, PHP's memory behaviour is shaped by configuration and internal allocation strategies. The memory_limit directive sets the hard cap for userland memory per request. But it's not the whole picture: memory_get_usage(true) reports the real memory (including internal heap overhead and fragmentation), while memory_get_usage(false) reports only user allocations. The difference between them can be surprisingly large — up to 30% on small scripts due to the Zend Memory Manager's chunk-based allocation.

When PHP allocates memory via the Zend MM, it requests large blocks (chunks, typically 256 KB) from the OS and then partitions them internally. This amortizes system calls but can lead to fragmentation over the lifetime of a long-running worker. The internal heap has its own free list and lazy coalescing — fragmentation is rarely a problem for short-lived FPM requests, but it becomes significant in CLI workers that run for hours.

Interned strings are another hidden memory consumer. When PHP encounters the same string literal multiple times, it stores it once in a shared interned string table. In CLI workers, this table persists for the process lifetime. While usually small (a few MB), if your code dynamically generates interned strings (e.g., via str_repeat in a loop), the table can grow unexpectedly.

Tuning: for CLI workers, consider increasing memory_limit to a safe upper bound, then implement a self-healing mechanism: monitor memory_get_usage(true) and restart the worker when utilisation exceeds a threshold (e.g., 80% of limit). For FPM, keep memory_limit low enough to catch leaks early but high enough to handle peak loads. Never set memory_limit to unlimited in production — you'll swap instead of crash, and swapping kills performance.

MemoryTuning.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php
declare(strict_types=1);

/**
 * Demonstrates the difference between real and user memory usage,
 * and a pattern for self-healing worker restarts.
 */

function getMemoryStats(): array
{
    return [
        'user' => memory_get_usage(false),
        'real' => memory_get_usage(true),
        'peak' => memory_get_peak_usage(true),
        'limit' => ini_get('memory_limit'),
    ];
}

// --- 1. Real vs User memory gap ---
echo "=== Starting Gap Test ===\n";
$stats = getMemoryStats();
echo "User: " . round($stats['user']/1024, 1) . " KB\n";
echo "Real: " . round($stats['real']/1024, 1) . " KB\n";
echo "Gap: " . round(($stats['real'] - $stats['user'])/1024, 1) . " KB\n";

// --- 2. Simulate fragmentation: allocate and free many small blocks ---
echo "\n=== Fragmentation Simulation ===\n";
$fragments = [];
for ($i = 0; $i < 10000; $i++) {
    $fragments[] = str_repeat('x', 128); // 128-byte strings
}
// Free every other one to create holes
foreach ($fragments as $idx => $val) {
    if ($idx % 2 === 0) {
        unset($fragments[$idx]);
    }
}
gc_collect_cycles();
$statsAfter = getMemoryStats();
echo "User after fragment free: " . round($statsAfter['user']/1024, 1) . " KB\n";
echo "Real after fragment free: " . round($statsAfter['real']/1024, 1) . " KB\n";
echo "Gap widened? " . (($statsAfter['real'] - $statsAfter['user']) > ($stats['real'] - $stats['user']) ? 'Yes' : 'No' ) . "\n";
// Note: real usage often does not drop because freed chunks are kept for reuse.

// --- 3. Self-healing worker watchdog pattern ---
function workerHealthCheck(): void
{
    $limitBytes = 128 * 1024 * 1024; // 128 MB
    $threshold = 0.8 * $limitBytes;   // 102.4 MB

    $realUsage = memory_get_usage(true);
    if ($realUsage > $threshold) {
        echo "[WATCHDOG] Memory usage " . round($realUsage / 1024 / 1024, 1) . " MB exceeds " . round($threshold / 1024 / 1024, 1) . " MB. Restarting...\n";
        // In real worker: exit or throw to trigger supervisor restart
        exit(0);
    }
}

// Simulate a few iterations
for ($iter = 1; $iter <= 5; $iter++) {
    // Do some work
    $tmp = str_repeat('data', 1000);
    unset($tmp);
    workerHealthCheck();
    echo "Iteration $iter: OK\n";
}
Output
=== Starting Gap Test ===
User: 0.3 KB
Real: 256.0 KB
Gap: 255.7 KB
=== Fragmentation Simulation ===
User after fragment free: 325.6 KB
Real after fragment free: 512.0 KB
Gap widened? Yes
=== Self-healing Worker ===
Iteration 1: OK
Iteration 2: OK
Iteration 3: OK
Iteration 4: OK
Iteration 5: OK
[WATCHDOG] Not triggered (within 128 MB limit)
Always monitor real memory in long-running workers
Use memory_get_usage(true) in CLI workers — it includes the Zend MM heap overhead that accumulates over time due to fragmentation. The user-level false value can stay flat while real usage creeps up, giving you a false sense of safety until the OOM killer arrives.
Production Insight
The gap between real and user memory can reach 30% due to chunk-based allocation.
Interned string table grows in CLI workers — watch for dynamic string generation in loops.
Rule: implement a watchdog that restarts the worker when real memory exceeds 80% of limit.
Key Takeaway
memory_get_usage(true) shows real OS allocation — includes Zend MM overhead.
Fragmentation increases the gap over time in long-running workers.
Set a memory ceiling in your worker and restart proactively — don't wait for OOM.
● Production incidentPOST-MORTEMseverity: high

The Silent Worker OOM: A Static Cache That Never Evicted

Symptom
Worker process RSS grew monotonically from ~50 MB at start to >500 MB after 12 hours. No error logs until OOM kill. gc_status() showed zero cycles collected — no circular refs.
Assumption
The team assumed PHP-FPM resets static state between requests. But CLI workers don't — static properties survive forever. 'We used a static lookup table for speed — it just never cleared.'
Root cause
A static array in a repository class cached product configuration for every order processed. The cache had no eviction policy — grew unbounded with each job. No cyclic references, so the GC never touched it.
Fix
Add a clearCache() method called after each job iteration, or use WeakReference for cross-object references. Set a max cache size with an LRU eviction strategy. Monitor memory_get_usage() per job and alert on delta > 5%.
Key lesson
  • Static properties are process-level state — they live as long as the worker runs.
  • In CLI workers (queue, long-running scripts), every static cache needs an eviction strategy.
  • Memory growth without GC activity points to static accumulation, not circular references.
Production debug guideIsolate the root cause in under 5 minutes4 entries
Symptom · 01
Memory grows monotonically across queue jobs
Fix
Insert memory_get_usage(true) checkpoints at job start and end. If delta > 0 consistently, you have a leak. Add gc_collect_cycles() and check gc_status()['collected'] — if zero, suspect static/global accumulation.
Symptom · 02
Allowed memory size exhausted in FPM
Fix
Check memory_limit in phpinfo(). Use memory_get_peak_usage(true) to find allocation spikes. Profile with Blackfire or Xdebug to see which function allocates the most.
Symptom · 03
High peak memory but normal baseline per request
Fix
Temporary allocations are fine. Focus on memory_get_peak_usage() - memory_get_usage() > 20MB for the same function. Look for large temporary arrays or string concatenation in loops.
Symptom · 04
gc_collect_cycles() returns > 0 after every request
Fix
You have cyclic references being created per request. Use xdebug_debug_zval() to trace the cycle location. Consider WeakReference to break the loop structurally.
★ PHP Memory Debugging Cheat SheetFour common memory symptoms, their root causes, and the exact commands to diagnose and fix them.
Worker memory grows unbounded per iteration
Immediate action
Log memory_get_usage(true) at start/end of each job iteration.
Commands
memory_get_usage() / 1024 / 1024 . ' MB'
gc_status() to see roots and collected cycles
Fix now
Add a static cache eviction policy or WeakReference for cross-references.
Allowed memory size exhausted error+
Immediate action
Check current memory limit and peak usage.
Commands
memory_get_peak_usage(true) and ini_get('memory_limit')
Run Blackfire profile or xdebug_debug_zval() on the allocation-heavy function.
Fix now
Increase limit temporarily: ini_set('memory_limit', '512M') and optimize the big allocation.
GC pause too frequent (slow responses)+
Immediate action
Disable the GC temporarily and profile.
Commands
gc_disable(); // then measure response times
gc_collect_cycles() at controlled batch boundaries instead.
Fix now
Call gc_collect_cycles() once per 1000 iterations, not per request.
OOM in CLI but not FPM+
Immediate action
Check if static/global state accumulates.
Commands
memory_get_usage(true) after 10, 100, 1000 iterations.
gc_status()['roots'] — if > 0 but collected = 0, leak is static accumulation.
Fix now
Clear static caches after each job. Set memory_limit higher or restart worker periodically.
Reference Counting vs Cyclic GC
AspectReference CountingCyclic GC (Mark & Sweep)
TriggerEvery refcount decrementRoot buffer full (10k roots) or gc_collect_cycles()
SpeedO(1) — immediate on decrementO(N) — proportional to cycle graph size
Pause typeIncremental — spread across operationsStop-the-world — full collection pass
Handles cyclesNo — fundamental limitationYes — designed exactly for this case
Memory freedImmediately when refcount hits 0Deferred until GC runs
Can be disabledNo — core engine mechanismYes — gc_disable() / gc_enable()
PHP versionAll versionsPHP 5.3+
Best suited forShort-lived scalars, non-circular graphsObject graphs with parent/child back-refs
Monitoring APIxdebug_debug_zval()gc_status(), gc_collect_cycles()
WeakReference bypassN/AWeakReference prevents cycle formation entirely

Key takeaways

1
PHP's zval structure stores scalar values (int, float, bool, null) directly with no heap allocation in PHP 7+
they're copied by value with zero malloc overhead, which is why tight arithmetic loops are far cheaper than equivalent object manipulations.
2
Copy-on-Write means assigning a variable or passing it to a function is nearly free until mutation occurs
but using & (by-reference) opts you out of CoW entirely and can increase memory pressure by forcing early separation of shared values.
3
Circular references (A→B→A) permanently defeat reference counting
refcounts stay at 1 forever. Use WeakReference::create() to break cycles structurally (the preferred fix) or call gc_collect_cycles() at controlled batch boundaries (the defensive fix).
4
Static properties and static variables persist for the entire process lifetime under CLI workers, ReactPHP, Swoole, and any non-FPM runtime
treat them like global state and audit every one for unbounded growth before deploying long-running PHP services.
5
Monitor memory_get_usage(true) (real usage) not memory_get_usage(false) in long-running workers
the Zend MM heap overhead and fragmentation can cause real memory to grow even when user allocations stay flat.

Common mistakes to avoid

5 patterns
×

Forgetting `unset($item)` after `foreach ($array as &$item)`

Symptom
The last element of the array gets silently overwritten the next time $item is used anywhere in the same scope, causing data corruption that only manifests intermittently.
Fix
Always add unset($item) as the very next statement after any reference-based foreach loop, without exception.
×

Calling `gc_collect_cycles()` inside every tight inner loop

Symptom
A script that processes 1 million rows takes 3x longer than expected; profiling shows GC consuming 60% of CPU.
Fix
Call gc_collect_cycles() at batch boundaries (every 1,000 rows or every job), not per-row. Use gc_status()['runs'] to measure how often the collector actually triggers and calibrate your call frequency accordingly.
×

Assuming PHP-FPM workers reset static state between requests

Symptom
A static cache built in one request bleeds data into a subsequent request served by the same worker, causing phantom data or subtle security leaks.
Fix
While FPM does reset userland state between requests by re-executing the script, code that stores state in persistent resources (database connections via persistent_connect, APCu, shared memory) does NOT reset. Explicitly document which caches are request-scoped vs process-scoped, and use a request lifecycle hook to flush anything that must be per-request.
×

Using `&` (by reference) in function parameters to avoid copying large arrays

Symptom
Memory usage increases instead of decreasing because the referenced value forces other sharing locations to copy early (breaks CoW).
Fix
Pass large read-only arrays by value — CoW makes them essentially free. Only use & when you intend to mutate the original inside the function. Profile to confirm the impact.
×

Relying on the implicit GC root buffer threshold to clean up cycles

Symptom
Random latency spikes every 10,000 cycle candidates — GC pause happens at unpredictable moments under load. Memory grows between runs.
Fix
Instead, call gc_collect_cycles() at controlled boundaries (end of each job iteration) and use gc_status() to monitor collected cycles. This gives predictable, bounded GC pauses.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
PHP uses reference counting for memory management — but reference counti...
Q02SENIOR
Explain Copy-on-Write in PHP. If I write `$b = $a` where `$a` is a 100MB...
Q03SENIOR
You have a PHP queue worker that processes jobs in a loop. After 12 hour...
Q01 of 03SENIOR

PHP uses reference counting for memory management — but reference counting alone can't handle all cases. What scenario does it fail on, and how does PHP resolve it?

ANSWER
Reference counting fails on circular references (object A references object B, which references A). Both refcounts stay at 1 forever, so neither is freed. PHP resolves this with a cyclic garbage collector (PHP 5.3+) that runs a mark-and-sweep algorithm on a root buffer of potential cycles. Alternatively, use WeakReference to break the cycle structurally and avoid the GC entirely.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How do I increase PHP memory limit for a single script without changing php.ini?
02
Does PHP automatically free memory when a variable goes out of scope?
03
What is the difference between memory_get_usage() and memory_get_peak_usage() and which should I monitor?
04
What is the recommended memory_limit for a WordPress site vs a Laravel queue worker?
05
How do I detect memory fragmentation in a PHP CLI worker?
🔥

That's Advanced PHP. Mark it forged?

6 min read · try the examples if you haven't

Previous
PHP Fibers — Async PHP
13 / 13 · Advanced PHP