KwikTech Symfony · API Platform · Doctrine upgrades
Upgrade guide · 12 min read

Upgrading Doctrine ORM 2 to 3 (and DBAL 3 to 4) in a Symfony application

Annotations to attributes, lazy ghosts instead of proxies, custom DBAL types, removed APIs, the DoctrineBundle flags to set first, and how to verify the schema did not change.

Published 2026-09-12. Written for teams doing the upgrade themselves; if you would rather have it done, see fixed-price upgrades. Also available in Deutsch.

What you are actually upgrading

"Doctrine ORM 3" is three upgrades that usually happen together: ORM 2.x → 3.x, DBAL 3.x → 4.x, and annotations → attributes because ORM 3 dropped the annotation driver. DoctrineBundle 2.11 and newer supports both ORM majors, so every step can be done on your current Symfony version (6.4 or even 5.4) and shipped separately. ORM 2 reaches end of life in February 2027; DoctrineBundle 3 and API Platform 4 expect ORM 3.

Step 1: latest 2.x, and read the deprecations

composer require doctrine/orm:^2.20 doctrine/dbal:^3.9 doctrine/doctrine-bundle:^2.13 --with-all-dependencies

ORM 2.19+ and DBAL 3.6+ log a deprecation for every API that 3.0 and 4.0 removed, through doctrine/deprecations, which the Symfony PHPUnit bridge collects. Run the suite with SYMFONY_DEPRECATIONS_HELPER=max[self]=0 and you have the list. Typical entries:

  • "Using annotations for mapping is deprecated" → step 3.
  • "Calling flush() with an entity argument is deprecated" → flush() without arguments.
  • "Doctrine\ORM\Proxy\Proxy is deprecated" and "lazy ghost objects" → step 4.
  • "Statement::execute() is deprecated, use executeQuery()/executeStatement()" → step 5.
  • "Type::getName() is deprecated", "requiresSQLCommentHint() is deprecated" → custom types, step 5.
  • "ClassMetadataInfo is deprecated, use ClassMetadata" → rename.
  • "Doctrine\Common\Persistence" → Doctrine\Persistence (mostly already done in 2.x codebases).

Step 2: DoctrineBundle flags that make 2.x behave like 3.x

# config/packages/doctrine.yaml
doctrine:
    orm:
        enable_lazy_ghost_objects: true        # ORM 3 proxies, available on 2.x with DoctrineBundle 2.11+
        report_fields_where_declared: true     # 3.x inheritance mapping behaviour
        validate_xml_mapping: true
        controller_resolver:
            auto_mapping: false                # 3.x: entities are only resolved with #[MapEntity]
        identity_generation_preferences:
            Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity   # sequences → identity columns on 3.x

Each of these can break something specific. auto_mapping: false is the one that surprises people: every controller argument that was silently resolved from a route parameter now needs #[MapEntity]. Symfony 6.2+ has the attribute; Rector adds it.

#[Route('/orders/{id}')]
public function show(#[MapEntity] Order $order): Response {}

Step 3: annotations to attributes

// rector.php
use Rector\Doctrine\Set\DoctrineSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__.'/src'])
    ->withSets([
        DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,
        DoctrineSetList::DOCTRINE_ORM_29,
        DoctrineSetList::DOCTRINE_DBAL_40,
        DoctrineSetList::DOCTRINE_ORM_300,
    ]);
// Before
/**
 * @ORM\Entity(repositoryClass=OrderRepository::class)
 * @ORM\Table(name="orders", indexes={@ORM\Index(name="idx_status", columns={"status"})})
 */
class Order
{
    /** @ORM\Id @ORM\GeneratedValue @ORM\Column(type="integer") */
    private $id;

    /** @ORM\ManyToOne(targetEntity=Customer::class, inversedBy="orders") @ORM\JoinColumn(nullable=false) */
    private $customer;
}

// After
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
#[ORM\Index(name: 'idx_status', columns: ['status'])]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(inversedBy: 'orders')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Customer $customer = null;
}

Then switch the driver and drop the annotations package:

doctrine:
    orm:
        mappings:
            App:
                type: attribute     # was 'annotation'
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'
composer remove doctrine/annotations   # only when nothing else requires it (Symfony routing, API Platform 2, JMS)
bin/console doctrine:mapping:info
bin/console doctrine:schema:validate

schema:validate must report "The database schema is in sync" both before and after this PR. If the diff is not empty, an attribute translation lost an option (typical: columnDefinition, options={"default": ...}, custom @ORM\Index flags). Fix the mapping, not the database.

Step 4: lazy ghosts instead of proxies

ORM 3 generates lazy-loading entities with Symfony's VarExporter (enable_lazy_ghost_objects, enabled above). The observable differences:

  • Proxies no longer implement Doctrine\ORM\Proxy\Proxy or Doctrine\Persistence\Proxy with __load(). Code that checked instanceof Proxy to force-load or to get the "real" class should use ClassUtils::getClass()-free approaches: $em->getClassMetadata($entity::class)->getName() or $em->initializeObject($entity).
  • Uninitialized ghosts have no property values, not even the identifier, until touched; $order->getId() initializes the object (the ORM optimizes identifier access, but code that reads properties by reflection does not benefit).
  • Serializing an uninitialized ghost (Symfony Serializer, json_encode after __serialize) triggers a load; make sure that happens inside a request with an open connection.

Step 5: DBAL 4

DBAL 4 is where custom types and raw SQL break. Checklist:

// Custom type, DBAL 4 signatures
final class MoneyType extends Type
{
    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
    {
        return $platform->getIntegerTypeDeclarationSQL($column);
    }

    public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Money
    {
        return null === $value ? null : Money::fromCents((int) $value);
    }

    public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?int
    {
        return $value?->cents();
    }

    // getName() and requiresSQLCommentHint() are gone: register the type by name in doctrine.yaml
}
doctrine:
    dbal:
        types:
            money: App\Doctrine\MoneyType
  • Types::ARRAY and Types::OBJECT (PHP serialize() columns) were removed in DBAL 4. Migrate those columns to json with a data migration; there is no shortcut.
  • Raw queries: $conn->executeQuery($sql, $params)->fetchAllAssociative(); fetchAll(), fetch(), execute() on statements are gone.
  • Platform class names changed (MySQL80Platform, PostgreSQLPlatform); set server_version in the DBAL config so Doctrine picks the right one without a connection at compile time.
  • Connection::getSchemaManager() → createSchemaManager(); Schema::getTables() returns objects keyed differently; migrations that manipulate schema objects directly need a look.

Step 6: the bump

composer require doctrine/orm:^3.5 doctrine/dbal:^4 doctrine/doctrine-bundle:^2.15 --with-all-dependencies
bin/console doctrine:schema:validate
bin/console doctrine:migrations:diff   # must generate nothing, or only the PostgreSQL identity change you chose

Removed APIs you may still hit at this point, none of which produce a deprecation in 2.x because they were replaced long ago: EntityManager::create() (use new EntityManager($connection, $config), or just the container), Setup::createAnnotationMetadataConfiguration() (ORMSetup::createAttributeMetadataConfiguration()), EntityManager::merge(), detach() semantics, Query::iterate() (toIterable()), and the Doctrine\ORM\Tools\Pagination\Paginator constructor's $fetchJoinCollection default.

Extensions: gedmo/doctrine-extensions 3.14+, stof/doctrine-extensions-bundle 1.10+, knplabs/doctrine-behaviors 2.6+, vich/uploader-bundle 2.x and doctrine/doctrine-fixtures-bundle 3.6+ support ORM 3. The compatibility pages show which release each bundle needs for Symfony 7.4; check its ORM constraint on Packagist for the ORM side.

Order of the pull requests

  1. Latest 2.x/3.x, deprecations visible
  2. DoctrineBundle flags on (each flag can be its own PR if the suite is thin)
  3. Annotations → attributes, schema:validate clean
  4. Custom types and raw SQL on DBAL 4 signatures
  5. ARRAY/OBJECT columns → JSON with data migration
  6. Bump ORM 3 + DBAL 4, migrations:diff empty

As an add-on to a Symfony upgrade this is €1,500 to €3,500; the range is driven by the number of custom types, serialized-array columns and raw DBAL calls.

Is Doctrine ORM 2 on your composer.lock?

The scan detects ORM below 3 and doctrine/annotations as cliffs, shows the DoctrineBundle release you need, and puts the ORM step in the right place in your upgrade path.

Scan my project Request an assessment (€750)

More guides