Lazy Objects — Deferred Initialization, Built Into Core
Every serious PHP framework had already built this. Doctrine had proxies. Symfony's dependency injection container had its own. Each one leaned on generated subclasses, __get tricks, and reflection to fake an object that looks real but hasn't loaded yet. PHP 8.4 put it in the engine.
class Report {
public function __construct(private PDO $db, private int $id) {
// Pretend this is an expensive query.
$this->rows = $db->query("SELECT * FROM huge WHERE id = $id")->fetchAll();
}
public array $rows;
}
$reflector = new ReflectionClass(Report::class);
// Nothing happens yet. No constructor, no query.
$report = $reflector->newLazyGhost(function (Report $report) use ($db, $id) {
$report->__construct($db, $id);
});
// Still nothing. Passing it around is free.
$service->schedule($report);
// The first real property access triggers initialization, right here.
echo count($report->rows);
Two flavors ship in core. A ghost is the object itself, hollow until touched, then initialized in place. A proxy forwards to a separate instance created on demand, which is what you want when the real object comes from somewhere else entirely.
// Ghost: becomes itself, later.
$ghost = $reflector->newLazyGhost(fn (Report $r) => $r->__construct($db, $id));
// Proxy: stands in for an instance you build on demand.
$proxy = $reflector->newLazyProxy(fn (Report $r) => new Report($db, $id));
// Both are real Report instances as far as the type system cares.
var_dump($ghost instanceof Report); // true
var_dump($proxy instanceof Report); // true
// Inspect without triggering it.
var_dump($reflector->isUninitializedLazyObject($ghost)); // true
$reflector->initializeLazyObject($ghost); // force it
var_dump($reflector->isUninitializedLazyObject($ghost)); // false
The detail that makes it work: $ghost instanceof Report is true, because it is a Report. No generated subclass, no interface to implement, no cache directory full of proxy classes. Type declarations, readonly, and property hooks all behave. The laziness is invisible to every line of code that receives the object.
This is what "batteries included" should mean. A pattern proved itself across a decade of userland frameworks, and rather than leaving everyone to reimplement it with reflection, the language absorbed it.