Object memory model — references, garbage collection, refcount
Concept
Objects in PHP are managed differently from scalar values. Understanding the object memory model — how objects are stored, referenced, and garbage collected — prevents memory leaks and explains behavior that surprises developers coming from other languages.
Object store: All objects live in a global EG(objects_store) — a dynamic array of zend_object structs. Each object has a unique integer handle (index in this array). Zvals holding objects store this handle, not a pointer or the object data itself.
Reference counting for objects: Each zend_object has a gc_refcount. When you assign $b = $a (both holding an object), the handle is copied to $b's zval but the zend_object's refcount increments. When a variable is unset or goes out of scope, refcount decrements. When it reaches zero, the destructor is called and the object is freed.
Cyclic reference problem: If $a->b = $b and $b->a = $a, both objects hold references to each other. When external variables to both are unset, their individual refcounts drop to 1 (not 0, because they reference each other). Reference counting alone can't detect this cycle — PHP's cycle collector (gc_collect_cycles()) runs periodically to detect and free such cycles. Laravel's long-lived worker processes can accumulate unreachable cycles if gc is disabled.
WeakReference (PHP 8.0): Holds a reference to an object WITHOUT incrementing its refcount. If the object is freed elsewhere, WeakReference::get() returns null. Used to observe objects without preventing their garbage collection — ideal for caches, event listeners, and observer patterns.
Code Example
<?php
declare(strict_types=1);
// Object handle vs object data
$a = new stdClass();
$a->x = 1;
$b = $a; // $b holds the SAME handle as $a
$b->x = 99;
echo $a->x; // 99 — same underlying object
$b = new stdClass(); // $b now holds a DIFFERENT handle
$b->x = 0;
echo $a->x; // still 99 — $a unchanged
// Cyclic reference — objects reference each other
class Node
{
public ?Node $next = null;
public function __construct(public string $label) {}
public function __destruct() { echo "Destroying {$this->label}\n"; }
}
$nodeA = new Node('A');
$nodeB = new Node('B');
$nodeA->next = $nodeB;
$nodeB->next = $nodeA; // cycle!
unset($nodeA, $nodeB); // refcounts: A→1 (B still refs it), B→1 (A still refs it)
// Neither destructor called here — they're stuck in a cycle
$freed = gc_collect_cycles(); // manually trigger cycle collector
echo "Freed $freed objects\n"; // "Freed 2 objects" — destructors now run
// WeakReference — observe without preventing GC
$obj = new stdClass();
$obj->name = 'Ephemeral';
$weak = WeakReference::create($obj);
echo $weak->get()?->name; // "Ephemeral"
unset($obj); // refcount drops to 0, object freed (WeakRef doesn't increment refcount)
var_dump($weak->get()); // NULL — object was freed
// Memory monitoring
function measureObjectMemory(int $n): void
{
$before = memory_get_usage();
$objects = [];
for ($i = 0; $i < $n; $i++) {
$objects[] = new stdClass();
}
$perObject = (memory_get_usage() - $before) / $n;
echo "Memory per object: {$perObject} bytes\n"; // ~64-72 bytes
unset($objects); // refcounts → 0, all freed
}
measureObjectMemory(10_000);