Fifty ghosts initialized in a loop are still fifty one queries.

PHP 8.4 is out since Thursday. Property hooks get the headlines, but the feature I keep coming back to is native lazy objects. It legalizes a trick ORMs have done with generated code for fifteen years.

The trick: you load an order, $order->customer should be a Customer, and you do not want a query until someone actually touches it. Doctrine generates a proxy class at build time, a subclass that overrides every method with “initialize first, then call parent”. It works. It is also a pile of magic: generated files, edge cases with final classes and private properties, strange things in var_dump.

Now the engine does it:

$reflector = new ReflectionClass(Customer::class);
$customer = $reflector->newLazyGhost(function (Customer $c) use ($id, $db) {
    $c->hydrateFrom($db->findCustomerRow($id));
});

You get a real Customer, correct class, passes every type check. The closure runs at first property access. There is the ghost variant, where the object fills itself in place, and a proxy variant that delegates to a separately created instance. No generated subclass, no build step. For Doctrine and anything Doctrine-shaped this is a better foundation, and I expect proxy generation to shrink release by release.

Two sober notes.

A lazy object initializes when something pokes it, and debuggers love to poke. Your var_dump in a log can be the thing that fires ten queries. Same for a serializer walking properties.

And the old disappointment. Lazy loading does not fix N+1, it industrializes it. The proxy makes each extra query cheap to write, not cheap to run. Exactly the bug from my Kohana post in 2014. Ten years, three frameworks, one bug. The language now decides who implements the laziness. The SQL log is still mine to read, and I still read it too late.