Dump an entity graph on a PHP 8.4 stack and the output looks almost boring. Where you used to get Proxies\__CG__\App\Entity\Customer, some __initializer__ and __cloner__ properties and a mental note to ignore all of it, you now get a lazy ghost object(App\Entity\Customer) whose name property reads uninitialized(string). That cosmetic difference is the biggest practical win of native lazy objects, and I'd argue it's a bigger win than anything on the performance side. The thing you are debugging is finally the class you wrote. What it is not is a fix for the query patterns that made lazy loading painful in the first place.

The old machinery deserves credit before we bury it. Without engine support, Doctrine's ProxyFactory and Symfony's LazyGhostTrait and LazyProxyTrait had to invent laziness out of the only tools userland had: generate a class at runtime that extends or mimics the original, override methods to forward to a real instance, or intercept reads and writes through __get() and __set() and initialize in place. Then eval() it or write it to disk and include it, and lean on OPcache so you weren't paying for the generation on every single request. It worked for a decade. It was also an enormous amount of code standing between you and a var_dump.

The replacement is a one liner in comparison. ReflectionClass::newLazyGhost() takes a closure, hands you an object of the real class, and PHP itself watches for the first access to an uninitialized property. What I find genuinely elegant is how Symfony wired its container around it: when a service is marked #[Lazy], the generated factory method gets called twice. First pass, $lazyLoad is still the default true, so getMailerServiceService::do() builds a ghost and stores it in $container->privates in the same expression. Second pass, the initializer fires and $lazyLoad is the ghost itself, so the check against true fails and the code calls __construct() on the object that already exists. Same instance, same shared service, no wrapper delegating calls to a hidden twin. Doctrine plays the same trick from the other end: it sets the identifier straight through the property accessor so that writing id doesn't trip the initializer it just attached.

Now the part that bothers me. The trigger moved, and most people haven't internalized where it moved to. Calling $order->getCustomer() executes nothing, because you're just reading a property on Order that already holds the ghost. The SELECT against customer fires the moment something reads state that isn't there yet, which in the example is getName() touching $name. Put that in a loop over fifty orders in a Twig template and you have the same N+1 you've always had, except the object no longer looks suspicious. It has the right class name, the right methods, the right everything.

The honest counter-argument is that the old proxies were ugly in a useful way. A class called Proxies\__CG__\App\Entity\Customer sitting in a stack trace was a signal that a junior dev could learn to read in an afternoon. The generated file was on disk, you could open it, you could set a breakpoint inside __load() and watch exactly who was forcing initialization and from where. Native ghosts give you none of that. There's no file, no method to break on, and the initialization happens inside the engine on an ordinary looking property read. If your team debugged lazy loading by grepping dumps for __CG__, you have genuinely lost a tool.

I still land on native being the right trade. The engine handles typed and readonly properties correctly instead of the framework negotiating with them; there's no eval, no proxy directory to warm during deploy, no cold cache penalty on the first request after a release. Object identity stops being a special case, which quietly removes a whole family of bugs where a proxy and its real instance disagreed about who they were. And when something goes wrong at three in the morning, reading a dump that says exactly which class you're holding is worth more than a debugging habit you can rebuild. You just have to rebuild it deliberately.

Concretely, that means moving the signal from the object to the query log. Count queries in your functional tests and assert on the number, not just the response body; a route that jumps from four queries to forty should fail CI, not wait for someone to notice the Doctrine panel in the profiler. Mark associations eager when they're used on every code path anyway, and reach for a fetch join in the repository instead of hoping laziness saves you. And be a bit sparing with #[Lazy] on services: a ghost around a constructor that assigns two strings buys you nothing and costs you a level of indirection. Which brings me to the thing I'd actually like to argue about in the comments: where do you draw that line? Do you mark services lazy by default and only opt out when you measure a problem, or do you keep laziness for the handful of services whose constructors open connections and read files? I've flip flopped on this twice this year and I'm curious what your production numbers say.