Open the Doctrine proxy folder of any project older than a year. A generated subclass for every entity, getters overridden, one file per class in the cache dir. It worked for fifteen years, and the seams always showed: final classes, public typed properties, instanceof surprises, a folder of code you pretend not to see.
PHP 8.4 lazy objects were designed with exactly this in mind. The native way needs no generated class:
$reflector = new ReflectionClass(User::class);
$user = $reflector->newLazyGhost(function (User $user) use ($loader) {
$loader->hydrate($user); // one query, runs on first access
});
The ghost is a real User. Same class, no subclass, no file in cache. The engine intercepts the first property access and runs the initializer. Everything Doctrine did with __get and generated code becomes a language feature. The runtime does the ugly part, the ORM shrinks.
Now the part that did not change.
The query still fires on first access. Loop over fifty orders, touch $order->customer->name, and you get fifty one queries. I wrote about this exact bug in 2014, with Kohana. Eleven years, three ORMs, one bug.
Native lazy objects make lazy loading better implemented. They do not make it cheaper. Fetch strategy is still your job: join, batch by ids, or accept the lazy hit and know that you did.
So when your ORM switches to native lazy objects, take the upgrade. Enjoy deleting the proxy folder. Then open the SQL log and count. I counted last week, on code I wrote myself. Fifty one.