If you have ever wondered why Doctrine entities cannot be final, this is the article that answers it. And the answer stops being true in PHP 8.4.
This is the largest change in the Symfony 8 and Doctrine 4 move, and it is not really a framework change at all. It is a PHP language feature removing the need for a fifteen-year-old workaround.
What the old system actually did
Lazy loading is the reason you can fetch a Post and access $post->getAuthor()->getName() without Doctrine having loaded every author in your database up front. The author is fetched only when you touch it.
Before PHP 8.4, implementing that required a genuinely elaborate trick:
- Generate a class
UserProxythat extendsUser. - Instantiate
Post::$authorwith aUserProxyrather than aUser. - In the proxy constructor,
unset()the declared properties. - Implement
__get, which PHP calls only for properties that do not exist. Because step 3 removed them, the interceptor fires. - On the first access, load the row and set the properties back.
It works, and it is a hack built on the interaction between unset() and magic methods. Two consequences followed from it, and both shaped how people write Doctrine entities.
Entities cannot be final. The proxy has to extend your class. A final class cannot be extended. This is the single most-asked question about Doctrine entity design, and the answer was always this implementation detail rather than anything about domain modelling.
Proxies are generated code on disk. A build step generates them, a directory holds them, and production configuration controls whether they regenerate at runtime. If you have ever debugged a stale proxy after a deployment, that is what you were looking at.
What PHP 8.4 changed
PHP 8.4 added lazy objects to the object model itself. Doctrine can now create a lazy instance of your actual entity class, with no subclass and no generated code:
$proxy = $classMetadata->reflClass->newLazyGhost(
function (object $entity) { /* load it */ }
);
That is the whole mechanism. The object is a real User. Its properties are uninitialised. Touching one runs the initialiser, which loads the row.
Worth adopting the correct terminology while you are here, because the Doctrine team raised it during review: this is a ghost, not a proxy. A proxy is a stand-in object that wraps a real one. A ghost is the real object with its data not yet populated. Doctrine's old implementation was a proxy. The new one is not, and the word "proxy" survives in the configuration mostly for historical reasons.
Where the versions sit
| Version | Native lazy objects | PHP |
|---|---|---|
| ORM 2.x | no | 7.1+ |
| ORM 3.0 to 3.3 | no | 8.1+ |
| ORM 3.4+ | opt-in | 8.1+, feature needs 8.4 |
| ORM 4.0 (not yet released) | always on | 8.4+ |
ORM 3.4 landed the opt-in in mid-2025. ORM 4.0 is still in development at the time of writing. It is planned to be built entirely on native lazy objects and to require PHP 8.4, with the code generation removed rather than made optional.
For context on how much of the ecosystem this affects: Doctrine reported that roughly 75% of installations were still on ORM 2.x when 3.4 shipped. If that is you, the path below matters more, not less.
Turning it on
In plain Doctrine:
$config->enableNativeLazyObjects(true);
In Symfony, this comes through DoctrineBundle 3. Upgrade the bundle and the configuration handles it.
The shortcut worth knowing
This is the most useful thing in this article if you are still on ORM 2.x.
There was an intermediate step in the roadmap. Doctrine planned to move from its own proxy library to symfony/var-exporter lazy ghosts, and that migration is what the deprecation notices tell you to do.
Doctrine now recommends skipping it. If you are heading to 3.4+ and will be on PHP 8.4, go straight to native lazy objects and ignore the intermediate deprecation:
use Doctrine\Deprecations\Deprecation;
$config->setLazyGhostObjectEnabled(false);
Deprecation::ignoreDeprecations(
'https://github.com/doctrine/orm/pull/10837/'
);
Then handle your other deprecations, upgrade to 3.4.x, and enable native lazy objects in one step. That removes an entire migration from your plan, and it is not obvious from the deprecation message itself, which still points at the older path.
What disappears from your configuration
Once native lazy objects are on, a set of settings stop being needed. Doctrine's documentation now marks them as required "except if you use native lazy objects with PHP 8.4" and slated for removal.
# config/packages/doctrine.yaml
doctrine:
orm:
auto_generate_proxy_classes: false # gone
proxy_dir: '%kernel.cache_dir%/doctrine/orm/Proxies' # gone
proxy_namespace: Proxies # gone
Leaving them in place after upgrading DoctrineBundle 3 produces a cache clear error rather than being quietly ignored, so this is not optional cleanup.
Check your deployment pipeline too. If you have a step that generates proxy classes, or a warmup that depends on the proxy directory existing, remove it. This is the practical payoff: one fewer build artefact, one fewer thing to get stale, one fewer directory to have permission problems with.
The payoff: entities can be final
No subclass is generated, so nothing needs to extend your entity. The constraint that made final impossible is gone.
That is a real change for how you model a domain. final by default is a reasonable design position, and Doctrine users have had to make an exception for their entire persistence layer purely because of a code generation strategy.
I would verify this on your own setup before relying on it. Doctrine uses reflection in several places, and I have not tested every combination of ORM version and bundle. But the reason for the restriction has been removed, which is the part that matters.
There is a second benefit that is less visible and arguably larger. Native lazy objects let Doctrine treat partial objects as lazy objects that load their missing properties on access. Under the old system, a partial object was a permanently incomplete entity and a well-known source of bugs, which is why partial queries were deprecated. With lazy objects the distinction between a full object, a partial object and a proxy stops being something your code has to know about.
Two gotchas
The PHP 8.3 exception. There was a bug in ORM 3.4.3 where enableNativeLazyObjects(false) threw a LogicException on PHP below 8.4, regardless of the value passed. Symfony's compiled container generated that call, so applications on PHP 8.3 with ORM 3.4.x hit it at cache warmup with a message about lazy loading proxies requiring PHP 8.4 when they had explicitly disabled the feature. It was fixed quickly, so upgrade past 3.4.3 rather than working around it.
Extensions that inspect proxies. Anything that checks whether an object is a proxy, by class name or by instanceof against a proxy interface, needs revisiting. Doctrine Extensions had to change for this. If you have custom code doing str_contains($class, 'Proxies\\') or similar, it will silently stop matching. Use the object manager's initialisation check rather than inspecting class names.
What to do
If you are planning the Symfony 8 move described in the upgrade article, this is one of the larger pieces and it has its own PHP requirement.
- Find out your PHP 8.4 date. Everything here depends on it, and hosting timelines are usually slower than framework ones.
- Get to ORM 3.4+, ignoring the var-exporter lazy ghost deprecation as above rather than acting on it.
- Grep for proxy inspection in your own code and in any Doctrine extensions you use.
- Find the proxy generation step in your deployment and note it for removal.
- Enable native lazy objects once you are on PHP 8.4, and delete the proxy configuration in the same commit.
The nice thing about this particular migration is that the end state is smaller than the start. Less configuration, no generated code, no build step, and a fifteen-year-old constraint on your domain model quietly lifted.