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 logicallyecho"=== 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 hereecho"\n=== After mutating copiedCity (CoW copy triggered) ===\n";
xdebug_debug_zval('cityName'); // refcount=1, is_ref=false — back to exclusivexdebug_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 wrapperecho"\n=== After creating a reference ===\n";
xdebug_debug_zval('originalScore'); // refcount=2, is_ref=trueecho "\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);
/**
* DemonstratesCopy-on-Write (CoW) memory behaviour with a large dataset.
* Shows how PHP avoids copying until it absolutely must, and what breaks CoW.
*/
functiongetMemoryUsageMB(): float
{
returnround(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 ---functioncountExpensiveProducts(array $catalogue, float $threshold): int
{
// We only READ the array — CoW means no copy happened when $catalogue was receivedreturncount(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.
*/
functiongetMemoryKB(): int
{
return (int)(memory_get_usage() / 1024);
}
// --- 1. THE LEAK: two objects referencing each other ---classOrderItem
{
public string $productName;
public ?Order $parentOrder = null; // back-reference — the cycle-forming linkpublicfunction__construct(string $productName)
{
$this->productName = $productName;
}
}
classOrder
{
public int $orderId;
/** @varOrderItem[] */
publicarray $items = [];
publicfunction__construct(int $orderId)
{
$this->orderId = $orderId;
}
publicfunctionaddItem(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 isolationgc_disable();
$memBefore = getMemoryKB();
for ($iteration = 0; $iteration < 5_000; $iteration++) {
$order = newOrder($iteration);
$order->addItem(newOrderItem('Widget A'));
$order->addItem(newOrderItem('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 ---classOrderV2
{
public int $orderId;
/** @varOrderItemV2[] */
publicarray $items = [];
publicfunction__construct(int $orderId)
{
$this->orderId = $orderId;
}
publicfunctionaddItem(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;
}
}
classOrderItemV2
{
public string $productName;
public ?WeakReference $parentOrderRef = null; // does NOT hold a strong refpublicfunction__construct(string $productName)
{
$this->productName = $productName;
}
publicfunctiongetParentOrderId(): ?int
{
$parentOrder = $this->parentOrderRef?->get(); // returns null if Order was GC'dreturn $parentOrder?->orderId;
}
}
$memBeforeV2 = getMemoryKB();
for ($iteration = 0; $iteration < 5_000; $iteration++) {
$order = newOrderV2($iteration);
$order->addItem(newOrderItemV2('Widget A'));
$order->addItem(newOrderItemV2('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.
*/
finalclassMemoryMonitor
{\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 }publicfunctionbeginIteration(): void
{
$this->iterationCount++;
// Ensure we're measuring steady-state, not leftover allocation noiseif ($this->forceGcBeforeMeasure) {
gc_collect_cycles();
}
$this->baselineBytes = memory_get_usage();
}
publicfunctionendIteration(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 thresholdif ($deltaBytes > $this->alertThresholdBytes) {
echo" *** MEMORY ALERT: iteration leaked " . (int)($deltaBytes / 1024) . " KB — investigate immediately ***\n";
}
}
publicfunctionsummary(): 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)classProductRepository
{
/** @vararray<int, array<string,mixed>> */
private static array $queryCache = []; // grows forever — classic long-running leakpublicstaticfunctionfindById(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 jobpublicstaticfunctionclearCache(): void
{
self::$queryCache = [];
}
}
functionprocessOrderJobClean(int $orderId): void
{
// Fetches product but clears the static cache after each jobProductRepository::findById($orderId);
ProductRepository::findById($orderId + 1);
ProductRepository::clearCache(); // releases static cache — memory returns to baseline
}
functionprocessOrderJobLeaking(int $orderId): void
{
// Fetches product but NEVER clears the cache — static grows unboundedProductRepository::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 iterecho"--- 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) ---
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.
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.
*/
functiongetMemoryStats(): 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 holesforeach ($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 ---functionworkerHealthCheck(): 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 restartexit(0);
}
}
// Simulate a few iterationsfor ($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
Aspect
Reference Counting
Cyclic GC (Mark & Sweep)
Trigger
Every refcount decrement
Root buffer full (10k roots) or gc_collect_cycles()
Speed
O(1) — immediate on decrement
O(N) — proportional to cycle graph size
Pause type
Incremental — spread across operations
Stop-the-world — full collection pass
Handles cycles
No — fundamental limitation
Yes — designed exactly for this case
Memory freed
Immediately when refcount hits 0
Deferred until GC runs
Can be disabled
No — core engine mechanism
Yes — gc_disable() / gc_enable()
PHP version
All versions
PHP 5.3+
Best suited for
Short-lived scalars, non-circular graphs
Object graphs with parent/child back-refs
Monitoring API
xdebug_debug_zval()
gc_status(), gc_collect_cycles()
WeakReference bypass
N/A
WeakReference 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.
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.
Q02 of 03SENIOR
Explain Copy-on-Write in PHP. If I write `$b = $a` where `$a` is a 100MB array, how much new memory is allocated at that exact moment, and what triggers an actual memory copy?
ANSWER
At that exact moment, zero new memory is allocated. PHP simply bumps the refcount of the array from 1 to 2 — both variables share the same data. An actual memory copy occurs only when one of the variables is modified (e.g., $b[0] = 'new'), at which point PHP separates the value and performs a full copy of the 100MB array. This is the core of Copy-on-Write optimization — it delays memory duplication until mutation is required.
Q03 of 03SENIOR
You have a PHP queue worker that processes jobs in a loop. After 12 hours it's using 2GB of RAM and gets OOM-killed. Walk me through your investigation: what tools do you use, what patterns do you suspect, and how do you fix them without restarting the process more frequently?
ANSWER
First, I'd add memory bracketing: log memory_get_usage(true) at start and end of each job. If there's a positive delta every iteration, that's a leak. Then I'd check gc_status() for collected cycles. If collected is 0 but memory grows, the leak is in static/global state — not circular references. I'd use xdebug_debug_zval() or Blackfire to locate the accumulating data structure. Common patterns: unbounded static cache, event listeners that never detach, or singletons holding references. The fix is to clear static caches per job, use WeakReference for cross-object dependencies, and implement a watchdog that restarts the worker when real memory exceeds 80% of limit. Avoid relying solely on restart frequency.
01
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?
SENIOR
02
Explain Copy-on-Write in PHP. If I write `$b = $a` where `$a` is a 100MB array, how much new memory is allocated at that exact moment, and what triggers an actual memory copy?
SENIOR
03
You have a PHP queue worker that processes jobs in a loop. After 12 hours it's using 2GB of RAM and gets OOM-killed. Walk me through your investigation: what tools do you use, what patterns do you suspect, and how do you fix them without restarting the process more frequently?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
How do I increase PHP memory limit for a single script without changing php.ini?
Call ini_set('memory_limit', '512M') at the top of your script before any heavy allocations. This overrides php.ini for that process only. You can also pass it via CLI with php -d memory_limit=512M script.php. Note: you can only increase the limit this way — if memory_limit is managed by a hosting provider with open_basedir restrictions, ini_set may be blocked.
Was this helpful?
02
Does PHP automatically free memory when a variable goes out of scope?
Yes — when a variable goes out of scope (function returns, loop iteration ends), its refcount is decremented. If the count reaches zero, PHP frees the memory immediately via the Zend Memory Manager. The exception is circular references: if two objects reference each other, their counts never reach zero and memory is only freed by the cyclic garbage collector, not automatically on scope exit.
Was this helpful?
03
What is the difference between memory_get_usage() and memory_get_peak_usage() and which should I monitor?
memory_get_usage() returns the current live memory footprint at the moment of the call. memory_get_peak_usage() returns the highest memory watermark the process has ever reached. For catching leaks, track memory_get_usage() across iterations (monotonic growth = leak). For capacity planning and finding temporary allocation spikes, monitor memory_get_peak_usage() — a script that peaks at 400MB but settles at 50MB may OOM under concurrent load even though its 'normal' usage looks fine.
Was this helpful?
04
What is the recommended memory_limit for a WordPress site vs a Laravel queue worker?
WordPress typically runs fine with 128MB per request (default in many shared hosts). A Laravel queue worker processing complex jobs may need 512MB to 2GB depending on data sizes. The key difference: FPM requests are short-lived, so even a temporary spike above limit causes a 500 error. CLI workers run indefinitely — a slow leak that adds 1MB per job will kill a 2GB worker after ~200,000 jobs. Set memory_limit high enough to handle peak per job, then implement a watchdog restart at 80% utilisation.
Was this helpful?
05
How do I detect memory fragmentation in a PHP CLI worker?
Compare memory_get_usage(true) - memory_get_usage(false) over time. If the gap grows while user allocations stay stable, fragmentation is increasing. You can also restart the worker periodically to reset the heap. The Zend MM does not defragment internally. A growing gap with no leak indicates fragmentation — restarting is the only practical remedy.