I still remember the first time I stepped through a Doctrine entity in a debugger and landed in a class called something like Proxies\__CG__\App\Entity\Invoice. Generated code, dumped into a cache directory, subclassing my entity, overriding every getter to sneak in a hydration call. It worked, and it also felt like the language was being wrestled into a shape it didn't want to hold. With PHP 8.4, that wrestling match is over. Laziness is now a property of the object model itself, exposed through ReflectionClass::newLazyGhost() and ReflectionClass::newLazyProxy(), and my position is simple: this is one of the most consequential runtime changes PHP has shipped in years, and even if you never call these methods yourself, you should understand them well enough to know which of the two shapes your framework picked and why.
Here is the distinction that actually matters, and it has nothing to do with syntax. A lazy ghost is one object for its whole life. You create it, its properties sit there uninitialized, and the moment something reads its state, PHP runs your initializer against that very same instance. Identity never changes. A lazy proxy is two objects wearing one trench coat: the placeholder you hand around, and the real instance a factory produces on first use. Both report the same class from get_class(), but compare them with === or feed them to spl_object_id() and the illusion cracks. That asymmetry is why I'd argue for a house rule: ghosts by default, proxies only when construction genuinely lives somewhere else, say behind a connection factory you don't control.
Why so cautious about proxies? Because identity bugs are the worst kind of quiet. Picture an SplObjectStorage used as a cache of processed entities, or an event system that deduplicates listeners by object identity. Someone stores the real instance the factory returned, someone else stores the proxy, and now the same logical object is in your set twice. Nothing throws. Your tests pass, because tests rarely mix the proxy and its target. Then production does, and you spend an afternoon staring at two objects that dump identically and refuse to be equal. A ghost simply cannot produce this bug, because there is no second object to confuse things with.
The counter-argument deserves a fair hearing: most application developers will never touch this API directly, so why should they care which flavor Symfony or Doctrine chose? It's true that this is plumbing. The follow-up article to the piece that prompted this column covers exactly how Symfony rebuilt its dependency injection and Doctrine its ORM lazy loading on top of the native model, and for many teams that will be the entire story: update, delete some generated proxy classes from your mental model, move on. But I've stopped believing in the category of 'infrastructure I don't need to understand'. The old generated subclasses leaked constantly, through final methods, through serialization, through reflection. The new model leaks too, just in better-defined places, and those places are now documented language semantics rather than framework trivia. When your lazy entity behaves oddly under clone, you can look up that cloning triggers initialization first and that __clone() runs on the real instance of a proxy, not on the proxy itself. That's a language rule you learn once, not a framework quirk you rediscover per project.
The engine-level design also earns its keep in the corners userland implementations always fumbled. If your initializer throws, PHP rolls the object back to its lazy state instead of leaving you with a half-hydrated zombie. Destructors on ghosts only run if initialization actually happened, so a lazy object you never touched won't fire cleanup logic for resources it never acquired. And my favorite detail: calling a method does not by itself wake the object up. Initialization triggers only when the engine has to observe state. A method that never reads a property runs happily on an uninitialized object. Try getting that granularity from a generated subclass that overrides every public method with an initialize-then-forward dance.
There's one feature I'd flag for anyone building anything ORM-shaped, even a small in-house mapper: ReflectionProperty::setRawValueWithoutLazyInitialization(), and its sibling pattern with skipLazyInitialization() followed by a normal setValue(). You often know an entity's ID long before you need its data, from a foreign key or a route parameter. Now you can plant that ID into a lazy object so that reading it costs nothing, while touching any other property still triggers the full load. That used to require careful bookkeeping inside hand-rolled proxies. Now it's two lines of reflection.
A few honest limits before you get carried away. Internal PHP classes can't be made lazy, so no lazy DateTimeImmutable, though user-defined classes and stdClass are fair game. And laziness is not free performance: a ghost that always gets initialized on the first line of every request is just a normal object with extra ceremony and a slightly worse stack trace. The pattern pays off when a real fraction of requests never touch the expensive thing at all, the mailer service on requests that never send mail, the session object on endpoints that never read it. If your profiler says everything initializes anyway, delete the laziness and construct eagerly with a clear conscience.
So here's where I land: adopt the mental model now, reach for newLazyGhost() when you have a genuine sometimes-unused expensive object, reserve newLazyProxy() for the factory-owns-construction case, and be grateful that a whole genre of code generation just became unnecessary. But I'm one working developer with one set of scars, and mine happen to be identity-shaped. What about yours? Have you already replaced a hand-rolled value holder or generated proxy with the native API in real code, and did the identity split of proxies ever actually bite you, or am I guarding against a bug you've never once seen in the wild? Tell me below, war stories especially welcome.
Comments
No comments yet — be the first.
Open the discussion
No account or password needed — just enter your e-mail and we’ll send you a one-time sign-in link. First time here? You’re set up automatically.
Your rating will be applied automatically after you sign in.
Check your inbox
We’ve sent a sign-in link to …. Open it on this device — this tab will sign you in automatically.
Nothing arrived? Check your spam folder — and mark the mail as "Not spam" so it lands in your inbox next time.