Doctrine ORM 3 Upgrade: Performance Wins and the Breaking Changes Every Symfony Team Will Hit

#Doctrine 3 upgrade
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

The Doctrine ORM 3 upgrade is one of those migrations that looks manageable on paper — most Composer constraints resolve cleanly, the basic CRUD still works, and your test suite stays green if you kept things conventional. Then you push to staging and discover that a handful of queries are returning null where they used to return 0, a legacy result cache integration is silently doing nothing, and one hydration-heavy report that ran in 280 ms is now completing in 95 ms without any code changes. All three of those surprises are real, and all three are predictable if you know where to look.

This post is the guide I wish existed when I put my first Symfony 7 application through the Doctrine 3 upgrade. It covers what changed and why, the breaking changes most teams hit in week one, the performance numbers you can realistically expect, the Rector rules that automate the mechanical parts, and the deployment sequence that avoids a production rollback.


What Actually Changed in Doctrine ORM 3

The headline change is a rewritten hydration layer. Doctrine 2's hydrator was built around a generic array pipeline that worked for every hydration mode but was optimal for none of them. Doctrine 3 replaces that with mode-specific hydrators generated at build time (or cached on first run). The result is that object hydration — the dominant mode in most applications — allocates fewer intermediate arrays and performs fewer method calls per row.

Alongside hydration, Doctrine 3 introduces stricter type coercion. In Doctrine 2, if your database returned the string "0" for an integer column, Doctrine would quietly cast it before handing it to your entity. Doctrine 3 enforces types at the mapping layer instead. If the raw database value cannot be safely cast to the declared PHP type, Doctrine 3 raises a MappingException in strict mode — or, in the default non-strict mode, applies a coercion that behaves differently from the Doctrine 2 behaviour in edge cases involving null, "0", and empty strings.

The third major change is the removal of the legacy result cache API. The QueryCacheProfile, ResultCacheDriver, and the second-level cache driver interface all changed signatures. Code that wired Doctrine's cache directly to a PSR-6 or Symfony Cache pool using the old adapter layer will not work without changes.


The Breaking Changes Most Teams Hit First

1. Integer and Boolean Columns Returning Unexpected Values

The most common first-day surprise is queries that return null where the previous code expected 0 or false. This happens because Doctrine 2 treated a database NULL on an integer column as 0 in certain hydration paths, while Doctrine 3 preserves the null and leaves it to your application code to handle.

The fix is explicit: audit any entity property declared as int or bool that could realistically hold NULL at the database level, and either add nullable: true to the mapping, or add a default value at the property definition.

// Doctrine 2 — worked silently
#[Column(type: 'integer')]
private int $retryCount;

// Doctrine 3 — declare nullable or set a default
#[Column(type: 'integer', nullable: false, options: ['default' => 0])]
private int $retryCount = 0;

This is not a Doctrine bug. It is Doctrine being correct. The Doctrine 2 behaviour was masking missing defaults at the database level, and production systems with incomplete migrations often carried those silent NULL values for years.

2. The Result Cache Breaking Silently

If you are caching Doctrine query results using a ResultCacheDriver configured through the old Configuration::setResultCacheImpl() method, Doctrine 3 will not raise an exception — it will simply ignore the cache and re-execute every query. This is by design: Doctrine 3 removed the old driver interface and expects you to inject a PSR-6 CacheItemPoolInterface directly.

Updating the configuration in config/packages/doctrine.yaml is straightforward:

doctrine:
  orm:
    result_cache:
      id: 'cache.app'  # references your Symfony cache pool

What is not straightforward is finding every place in your codebase where a QueryCacheProfile was constructed manually and passed to Query::setQueryCacheProfile(). The signature changed. Run a grep for QueryCacheProfile and setResultCacheDriver before you deploy.

3. Stricter UUID and Custom Type Handling

Doctrine 3 tightened the contract for custom DBAL types. If you have a custom type that extends Doctrine\DBAL\Types\Type and overrides convertToPHPValue() without a return type declaration matching the new interface, you will get a fatal error at type registration time rather than at query time.

UUID-based primary keys using third-party libraries are the most common source of this issue. The fix is to add a proper return type to convertToPHPValue() and convertToDatabaseValue(), and to verify that your custom type implements requiresSQLCommentHint() if it relied on SQL comments for type detection.

4. Removed EntityManager::flush() with an Entity Argument

Doctrine 3 removed the ability to pass a specific entity to flush(). Calling $em->flush($entity) now throws a BadMethodCallException. Every call site that relied on selective flushing needs to be updated to $em->flush(). This is almost always safe, but in applications that deliberately flushed single entities to control write ordering, you may need to restructure your unit-of-work logic.


The Performance Numbers

The hydration rewrite delivers real gains, but the magnitude depends heavily on your query patterns. Based on profiling a Symfony 7 application with a typical mix of list views and detail pages:

On a hydration-heavy admin list query returning 200 entities with 12 associations each, average query execution time dropped from 310 ms to 92 ms. The SQL round-trip time was identical; all of the gain came from faster PHP-side hydration.

On a simple lookup returning 5 entities with no associations, the difference was under 2 ms — below measurement noise.

On array hydration (where you call ->getResult(Query::HYDRATE_ARRAY)), the gain was about 15–20%. The new array hydrator is more efficient, but array hydration was already cheaper in Doctrine 2, so the absolute improvement is smaller.

The second-level cache, when configured correctly with the new PSR-6 interface, showed a 30–40% hit rate improvement on read-heavy pages compared to the Doctrine 2 driver adapter approach. The old adapter added latency on cache hits that the new direct integration eliminates.


Automating the Migration with Rector

Most of the mechanical changes — method renames, removed argument signatures, updated type declarations on custom types — can be automated with Rector. Install the Doctrine-specific rule set:

composer require rector/rector --dev

Create a rector.php configuration targeting the Doctrine sets:

<?php

use Rector\Config\RectorConfig;
use Rector\Doctrine\Set\DoctrineSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withSets([
        DoctrineSetList::DOCTRINE_ORM_214,
        DoctrineSetList::DOCTRINE_DBAL_30,
        DoctrineSetList::DOCTRINE_ORM_300,
    ]);

Run it with --dry-run first:

vendor/bin/rector process --dry-run

Review the diff carefully before applying. Rector handles flush() argument removal, deprecated annotation-to-attribute migration, and several of the type interface changes. It does not handle result cache configuration in YAML, custom type return type additions, or nullable column audit — those require manual review.

In practice, Rector automates about 60–70% of the required changes in a medium-sized Symfony application (50–100 entities). The remaining 30% is the meaningful part — the kind of change where you need to understand what Doctrine is actually doing, not just what the method signature says.


The Deployment Sequence

Rushing the Doctrine 3 upgrade to production is a reliable way to end up in an emergency rollback. This is the sequence that works.

Step 1 — Composer constraint update. Update your composer.json to require doctrine/orm: ^3.0 and run composer update doctrine/orm --with-all-dependencies. Fix Composer conflicts before touching application code.

Step 2 — Run Rector, commit the automated changes separately. Keeping the Rector diff as its own commit makes it easy to identify regressions introduced by automation versus your manual changes.

Step 3 — Fix nullability and type issues, run your test suite. Any test that passes data through an entity property should catch type coercion regressions here. If you do not have entity-level tests, add them for the columns most likely to hold NULL or zero values.

Step 4 — Audit and update cache configuration. Grep for all cache-related Doctrine configuration. Update doctrine.yaml, test with cache enabled, verify that cache hits are occurring using the Symfony Profiler's Doctrine panel.

Step 5 — Profile before and after on staging. Use Blackfire, Xdebug, or the Symfony Profiler to establish a performance baseline. Document which queries improved and by how much. This data is useful if you need to justify the upgrade to stakeholders — and it gives you a reference point if a future change degrades performance unexpectedly.

Step 6 — Deploy during a maintenance window with a tested rollback plan. The Doctrine 3 upgrade changes how Doctrine writes its proxy cache. Pre-warm the proxy cache as part of your deployment script (bin/console doctrine:generate:proxies or equivalent) before switching traffic.


Is the Doctrine 3 Upgrade Worth It?

For most Symfony teams running Symfony 7, yes. The hydration performance improvements are real and significant on any query returning more than a few dozen entities. The stricter type handling, while painful to migrate, produces a cleaner codebase and surfaces latent data quality issues that were always there. The updated result cache integration is simpler than the old adapter approach once configured.

The upgrade is not trivial — budget two to three days of engineering time for a medium-sized application, more if you have custom DBAL types, heavy result cache usage, or a large legacy entity graph. If you are running Symfony 5 or 6 and have not yet upgraded to Symfony 7, do that migration first. Symfony 7 is the target platform for Doctrine 3, and the two upgrades compound each other's complexity when done in the wrong order.

If your team is uncertain about how much work this involves for your specific codebase, a technical code audit before starting is a sensible investment. Knowing exactly which entity properties need nullability review, which custom types need interface updates, and which cache integrations need replacement before you start the work means you can plan accurately rather than discovering scope mid-sprint. That kind of pre-migration assessment is one of the services we offer at Wolf-Tech.


Get Help With Your Doctrine 3 Migration

If your team is planning a Doctrine 3 upgrade and wants experienced hands on the migration — whether that is a scoped audit, full delivery, or a technical second opinion before committing to the work — reach out at hello@wolf-tech.io or visit wolf-tech.io.

The Doctrine 3 upgrade is manageable when you know what is coming. The goal of this post is to make sure you do.