Garbage Collection
How PHP Frees Memory In simple terms: Every variable, array, and object needs memory. When a value is no longer used, PHP gives its memory back automatically — you never call free() as in C. Usually t
How PHP Frees Memory
In simple terms: Every variable, array, and object needs memory. When a value is no longer used, PHP gives its memory back automatically — you never call free() as in C. Usually this happens immediately. In one special situation — objects that refer to each other in a circle — PHP needs an extra helper, the garbage collector, to find and free them.
Technically: PHP manages memory with reference counting: every value (zval) tracks how many variables, array elements, or properties refer to it. When the count drops to zero, the value is destroyed immediately (and an object's destructor runs). Reference counting cannot free circular references — structures that point to themselves directly or indirectly — because their counts never reach zero. PHP's cycle collector periodically finds such unreachable cycles and frees them. Additionally, PHP releases all memory of a web request when the request ends.
Reference Counting in Action
<?php
final class Report
{
public function __construct(private string $name)
{
echo "Created {$this->name}\n";
}
public function __destruct()
{
echo "Freed {$this->name}\n";
}
}
$a = new Report('sales'); // refcount 1
$b = $a; // refcount 2 — both variables point to the same object
unset($a); // refcount 1 — still alive
echo "After unset(\$a)\n";
$b = null; // refcount 0 — destroyed immediately
echo "After \$b = null\n";
Output
Created sales
After unset($a)
Freed sales
After $b = null
Memory is released at the exact moment the last reference disappears. This makes PHP's memory behavior predictable: local variables are freed when a function returns, and temporary arrays are freed when they are overwritten (see PHP Destructor).
Copy-on-Write
Assigning an array or string does not copy it immediately:
$prices = range(1, 100_000); // one array in memory
$copy = $prices; // no copy yet — both share the same array
$copy[] = 100_001; // now PHP copies the array, because it is modified
PHP shares the value and copies it only when one side writes to it. This is why passing large arrays to functions is cheap as long as the function does not modify them — there is no need to pass arrays by reference "for performance".
The Problem: Circular References
<?php
final class Node
{
public ?Node $partner = null;
public function __construct(public string $name) {}
public function __destruct()
{
echo "Freed {$this->name}\n";
}
}
$a = new Node('A');
$b = new Node('B');
$a->partner = $b; // A → B
$b->partner = $a; // B → A
unset($a, $b);
echo "Variables are gone, but the objects still reference each other\n";
$collected = gc_collect_cycles();
echo "Cycle collector ran\n";
var_dump($collected > 0);
Output (the two "Freed" lines may appear in either order)
Variables are gone, but the objects still reference each other
Freed A
Freed B
Cycle collector ran
bool(true)
After unset(), no variable refers to A or B, but each object still holds a reference to the other, so both counts stay at 1. Only the cycle collector — here triggered manually with gc_collect_cycles() — detects that the pair is unreachable and frees it. gc_collect_cycles() returns the number of freed values.
When the Collector Runs Automatically
PHP records potential cycle roots in a buffer. When the buffer fills (10,000 roots by default; the threshold adapts automatically to the workload), the collector runs. You rarely need to call it yourself, except in long-running processes with many short-lived cyclic structures.
| Function | Purpose |
|---|---|
gc_collect_cycles() | Run the cycle collector now; returns the number of freed values |
gc_enabled(), gc_enable(), gc_disable() | Check or switch automatic collection |
gc_status() | Statistics: runs, collected values, threshold, buffered roots (PHP 8.3 adds timing information) |
gc_mem_caches() | Return cached memory blocks of the memory manager to the operating system |
Avoiding Cycles: Weak References
Many cycles come from objects that know their parent (a tree node referencing its parent) or caches that refer to objects. Weak references refer to an object without keeping it alive:
<?php
final class Customer
{
public function __construct(public string $name) {}
}
$customer = new Customer('Asha');
$reference = WeakReference::create($customer);
echo $reference->get()?->name . "\n";
unset($customer);
var_dump($reference->get());
Output
Asha
NULL
WeakReference::get() returns the object while it exists elsewhere and null after it has been destroyed. The weak reference itself does not count toward the reference count.
WeakMap: Data Attached to Objects
<?php
final class Order
{
public function __construct(public int $id) {}
}
$computedTotals = new WeakMap();
$order = new Order(501);
$computedTotals[$order] = 380.00;
echo count($computedTotals) . " cached entry\n";
unset($order);
echo count($computedTotals) . " cached entries\n";
Output
1 cached entry
0 cached entries
A WeakMap uses objects as keys without keeping them alive. When an object is destroyed, its entry disappears automatically — ideal for caches attached to objects, which otherwise grow forever in long-running processes.
Memory in Web Requests vs Long-Running Processes
| Situation | Behavior | What to Watch |
|---|---|---|
| Web request (PHP-FPM) | All request memory is released when the request ends | Peak memory per request (memory_limit) |
| CLI scripts and imports | Memory accumulates over the run | Process data in streams and batches (see PHP Generators) |
| Queue workers and daemons | Run for hours; leaks add up | Free large structures, avoid growing static caches, restart workers periodically |
| Application servers (Swoole, RoadRunner, FrankenPHP worker mode) | Objects survive between requests | Reset state between requests; avoid static caches and cycles |
Measuring Memory
<?php
$before = memory_get_usage();
$rows = array_fill(0, 100_000, ['sku' => 'PEN-01', 'qty' => 1]);
$during = memory_get_usage();
unset($rows);
$after = memory_get_usage();
printf("Array used about %.1f MB; after unset: %.1f MB above the start\n",
($during - $before) / 1048576,
($after - $before) / 1048576
);
Output (approximate; varies by PHP version and platform)
Array used about 2.0 MB; after unset: 0.0 MB above the start
All 100,000 elements are copies of the same small array, which PHP shares between them (copy-on-write), so the outer list needs only about 16 bytes per element (PHP 8.2+ stores simple lists compactly). unset() frees it immediately, because its reference count reaches zero. memory_get_peak_usage() reports the highest usage of the whole script — the figure to compare with memory_limit.
Common Mistakes
Calling gc_collect_cycles() Everywhere
Effect: Wasted CPU time; reference counting already frees most memory immediately.
Fix: Call it only in long-running processes after creating many cyclic structures, if measurements show a benefit.
Disabling the Garbage Collector "for Speed"
Effect: Memory grows without limit when cycles occur.
Fix: Leave it enabled; tune only with measurements.
Ever-Growing Static Caches in Workers
Effect: Memory leaks in long-running processes.
Fix: Bound caches, use WeakMap, or reset state between jobs.
Passing Arrays by Reference to "Save Memory"
Effect: No gain (copy-on-write already avoids copies) and harder-to-read code.
Fix: Pass by value unless the function must modify the caller's array.
Best Practices
- Rely on reference counting: release large data with
unset()or by letting variables go out of scope. - Avoid unnecessary circular references; use weak references and
WeakMapfor back-references and caches. - Stream and batch large data instead of loading it all at once.
- In long-running processes, monitor memory and restart workers periodically.
- Measure with
memory_get_usage(),memory_get_peak_usage(), andgc_status()before tuning.
Official reference: https://www.php.net/manual/en/features.gc.php