Home PHP PHP Memory Management Explained: Internals, Leaks & Optimization

PHP Memory Management Explained: Internals, Leaks & Optimization

In Plain English 🔥
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.
⚡ Quick Answer
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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344
<?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, scoreAlias: $scoreAlias\n";
$scoreAlias += 50;                 // modifies the shared reference — no copy!
echo "After modifying alias — originalScore: $originalScore\n"; // 150
▶ 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, scoreAlias: 100
After modifying alias — originalScore: 150
🔥
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.

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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
<?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 TrapAfter `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.

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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
<?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 WorkersIn 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 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.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
<?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
{
    private int $baselineBytes;
    private int $iterationCount = 0;
    private int $totalBytesLeaked = 0;

    public function __construct(
        private readonly int  $alertThresholdBytes = 1_048_576, // 1 MB per iteration
        private readonly bool $forceGcBeforeMeasure = true
    ) {
        $this->baselineBytes = memory_get_usage();
    }

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

  • 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.
  • 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.
  • 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).
  • 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.

⚠ Common Mistakes to Avoid

  • Mistake 1: 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.
  • Mistake 2: 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.
  • Mistake 3: 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.

Interview Questions on This Topic

  • QPHP 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?
  • QExplain 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?
  • QYou 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?

Frequently Asked Questions

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.

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.

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.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousLaravel Testing with PHPUnitNext →PHP Type Declarations
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged