PHP Garbage Collection Internals (Cycle Collector)
debt(d7/e5/b5/t7)
Closest to 'only careful code review or runtime testing' (d7). Memory leaks from circular references don't produce errors — they silently accumulate until the process is killed by the OS. Detection requires active monitoring with memory_get_usage() or observing OOM kills in production. No linter or static tool detects improper GC handling; it requires runtime profiling or careful code review of object lifecycle patterns.
Closest to 'touches multiple files / significant refactor in one component' (e5). The quick_fix suggests calling gc_collect_cycles() after batches, which is simple, but properly fixing the underlying issue often requires refactoring ORM entity relationships, breaking bidirectional references, or restructuring how objects are created in loops across multiple files in the data layer.
Closest to 'persistent productivity tax' (b5). Applies only to cli/queue-worker contexts (not typical web requests), but within those contexts it's a persistent concern. Every long-running worker must be designed with memory lifecycle in mind — monitoring memory_get_usage(), considering when to call gc_collect_cycles(), and being careful about ORM patterns. This shapes how you architect batch processing and queue consumers.
Closest to 'serious trap' (t7). The misconception explicitly states developers believe unset() calls the garbage collector — it does not. This contradicts how garbage collection works in other languages where 'freeing' memory is more immediate. The reference-counting + separate cycle-collector model is PHP-specific and unintuitive. Developers also wrongly expect WeakReference to prevent GC rather than allow it.
Also Known As
TL;DR
Explanation
Reference counting frees most PHP values immediately when they go out of scope. The problem is circular references: object A holds a reference to object B, and B holds a reference back to A. Neither can ever reach a reference count of zero, so reference counting alone would leak them forever. PHP's cycle collector (enabled by default, based on the Bacon-Rajan algorithm) periodically scans for reference cycles and frees them. It is triggered when the root buffer (a list of potential cycle roots) reaches 10,000 entries. You can trigger it manually with gc_collect_cycles(). The collector has a cost — it pauses execution while it scans. Long-running CLI scripts and queue workers that create many objects are most affected.
Common Misconception
Why It Matters
Common Mistakes
- Disabling the cycle collector with gc_disable() for 'performance' without profiling — the collector pause is usually negligible; disabling it causes memory leaks in long-running processes.
- Expecting WeakReference to prevent GC — WeakReference does not prevent collection; it allows you to hold a reference that does not prevent the referent from being freed.
- Not monitoring memory in queue workers — memory_get_usage() logged per batch reveals leaks early before the process is killed by the OS.
- Creating ORM entities with lazy-loaded bidirectional relationships in loops — Doctrine and Eloquent frequently create cycles via their identity map and proxy objects.
Code Examples
<?php
// ❌ Circular reference — both objects keep each other alive
class Order
{
public ?Customer $customer = null;
}
class Customer
{
public ?Order $order = null;
}
for ($i = 0; $i < 100000; $i++) {
$order = new Order();
$customer = new Customer();
$order->customer = $customer;
$customer->order = $order; // Cycle: Order → Customer → Order
// Both go out of scope here but refcount never hits zero
// Memory grows unboundedly
<?php
// ✅ Option 1: Break the cycle with WeakReference (PHP 7.4+)
class Customer
{
private WeakReference $order;
public function setOrder(Order $order): void
{
$this->order = WeakReference::create($order);
}
public function getOrder(): ?Order
{
return $this->order->get();
}
}
// ✅ Option 2: Periodically trigger the cycle collector in workers
for ($i = 0; $i < 100000; $i++) {
processJob($jobs[$i]);
if ($i % 1000 === 0) {
gc_collect_cycles(); // Explicit sweep
}
}