PHP garbage collection — cyclic references and gc_collect_cycles()
Concept
PHP uses reference counting as its primary garbage collection mechanism. Each value (zval or zend_object) has a refcount — the number of variables/data structures pointing to it. When refcount drops to zero, the value is immediately freed. This is fast and predictable — memory is released as soon as it's no longer reachable.
The reference counting problem — cyclic references: If object A holds a reference to object B, and object B holds a reference to object A, both have refcount >= 1 even when no external variable references them. They're unreachable but not freed. This is a memory leak in reference-counted systems.
PHP's cycle collector: PHP has a secondary garbage collector specifically for cycles. It runs automatically when the number of potential cycle roots exceeds a threshold (default 10,000). You can trigger it manually with gc_collect_cycles(). The algorithm: mark all potential cycle roots, do a simulated reference decrement, anything that reaches zero is part of a cycle and can be freed.
Long-running processes: In traditional PHP (one request per process), cycles are freed when the process terminates. In Laravel Octane, FrankenPHP, or Swoole, processes handle many requests. Cycles accumulate over time — eventually causing out-of-memory crashes unless you understand and manage them.
gc_disable() / gc_enable(): Disabling the cycle collector for a batch process that creates many objects and then frees them all at once is sometimes faster than letting the collector run periodically. Profile to verify.
Code Example
<?php
declare(strict_types=1);
// Cyclic reference — memory leak without cycle collector
class Node
{
public ?Node $parent = null;
public array $children = [];
public string $label;
public function __construct(string $label)
{
$this->label = $label;
}
public function addChild(Node $child): void
{
$child->parent = $this; // cycle: parent→child, child→parent
$this->children[] = $child;
}
public function __destruct()
{
echo "Freeing: {$this->label}\n";
}
}
// Create a tree with cyclic back-references
$root = new Node('root');
$child1 = new Node('child1');
$child2 = new Node('child2');
$root->addChild($child1);
$root->addChild($child2);
$before = memory_get_usage();
unset($root, $child1, $child2); // refcounts don't reach 0 due to cycles
$after_unset = memory_get_usage();
echo "Memory still in use (cycles): " . ($after_unset - $before) . "\n";
$freed = gc_collect_cycles(); // manually trigger cycle collection
$after_gc = memory_get_usage();
echo "Freed $freed objects, memory recovered: " . ($after_gc - $after_unset) . "\n";
// Destructors now run: "Freeing: root", "Freeing: child1", "Freeing: child2"
// Monitoring GC in long-running processes
$gcStatus = gc_status();
echo "Runs: {$gcStatus['runs']}, Collected: {$gcStatus['collected']}\n";
// Pattern for long-running workers: reset state between jobs
// In Laravel Queue workers (simplified):
class WorkerLoop
{
public function process(): void
{
while (true) {
$job = $this->fetchNextJob();
$job->handle();
unset($job);
gc_collect_cycles(); // ensure cycles from job are freed
}
}
}
// WeakReference to avoid cycles in observer patterns
$subject = new stdClass();
$subject->name = 'Observable';
$weak = WeakReference::create($subject);
// Observer holds a WeakReference — won't prevent GC of $subject
echo $weak->get()?->name ?? 'freed';