Migrating Doctrine ORM 2 to 3: Breaking Changes in Symfony
AI generated
SF
{ }
Symfony · Doctrine ORM 3 · Migration · Modernization
Migrating Doctrine ORM 2 to 3
handling breaking changes in Symfony projects safely

Doctrine ORM 3 replaces proxy classes with Lazy Ghost Objects, changes EntityManager methods, and tightens mapping for embeddables. Teams that know these breaking changes before migrating and follow a clear checklist can bring existing Symfony projects onto the new ORM version without production downtime.

20 min read Lazy Ghost Objects · EntityManager · Mapping Doctrine ORM 3.x · Symfony 7.x

1. Why the migration to Doctrine ORM 3 is due

Doctrine ORM 3 is the first major version in many years and deliberately clears out legacy baggage that accumulated since Doctrine ORM 2.0. For Symfony projects that rely on Doctrine as the primary persistence layer, this means: a plain composer require doctrine/orm:^3.0 is rarely enough, because central classes were removed, renamed, or fundamentally changed in behavior. Starting the migration without preparation quickly produces hundreds of errors that are hard to prioritize.

The effort still pays off, because Doctrine ORM 3 brings noticeable improvements: Lazy Ghost Objects replace the previously generated proxy classes and reduce both memory footprint and the complexity of debugging lazy loading. At the same time, mapping becomes more strictly typed, catching errors at development time instead of only at runtime. A team that plans the migration now also avoids being forced to work under time pressure later with an increasingly outdated Doctrine ORM 2 version that no longer receives new features.

This article walks through the migration from Doctrine ORM 2 to 3 step by step: from the central breaking changes, through the new Lazy Ghost Objects, to a practical checklist that structures the rollout across staging and production.

2. Breaking changes at a glance

The largest visible change when migrating from Doctrine ORM 2 to 3 concerns the namespace and structure of the persistence layer: parts of the shared persistence abstraction were moved into the separate doctrine/persistence package, affecting existing use statements across many repository and listener classes. In addition, methods marked deprecated in Doctrine ORM 2.x were consistently removed instead of merely carrying a warning as before.

Another central break concerns mapping: XML and YAML mapping drivers were removed from the core in favor of PHP attributes as the only supported format. Projects that still use Resources/config/doctrine/*.orm.xml files need to fully convert them to attributes before migrating, which is the most time-consuming single step of the entire migration in large codebases.


<?php
declare(strict_types=1);

// Doctrine ORM 2.x: EntityManagerInterface still exposed getConnection()
// with a return type that varied across minor versions
$connection = $entityManager->getConnection();

// Doctrine ORM 3.x: explicit, typed access via the Connection interface
use Doctrine\DBAL\Connection;

final class OrderRepository
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function runRawQuery(string $sql): array
    {
        $connection = $this->entityManager->getConnection();
        assert($connection instanceof Connection);

        return $connection->fetchAllAssociative($sql);
    }
}

Important for planning: not every bundle in your own Composer tree is already prepared for Doctrine ORM 3. Running composer why-not doctrine/orm ^3.0 before the actual upgrade reliably shows which dependencies are still blocking the new version, making it the first practical step of any migration.

3. EntityManager and changed lifecycle methods

The EntityManager itself lost several methods in Doctrine ORM 3 that were already considered problematic in ORM 2.x. merge(), which frequently caused unexpected side effects on detached entities, was removed entirely. Teams still using this pattern need to replace it with an explicit reload of the entity via its identifier and manually transferring changed fields, which means more code but makes the behavior significantly more predictable.

clear() with a class name as a parameter, which previously removed only entities of a specific type from the unit of work, no longer accepts any arguments in Doctrine ORM 3 and always clears the entire identity map. For batch processing that used to selectively remove individual entity types from memory, this means adjusting batch sizes or restructuring into smaller, fully isolated processing steps.


<?php
declare(strict_types=1);

// Doctrine ORM 2.x: merge() re-attached a detached entity
// $merged = $entityManager->merge($detachedOrder);

// Doctrine ORM 3.x: merge() removed, explicit reload instead
final class OrderReattacher
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function reattach(Order $detachedOrder): Order
    {
        $managed = $this->entityManager->find(Order::class, $detachedOrder->getId());

        if ($managed === null) {
            throw new \RuntimeException('Order no longer exists');
        }

        $managed->updateFrom($detachedOrder);

        return $managed;
    }
}

These removals may look like a limitation at first, but they are deliberate: merge() was a common source of hard-to-trace bugs, especially when entities were passed between processes across serialization boundaries. The explicit alternative makes the data flow visible instead of hiding it behind a seemingly convenient method.

4. Lazy Ghost Objects instead of classic proxy classes

The technically biggest leap in Doctrine ORM 3 is the switch from generated proxy classes to PHP-native Lazy Ghost Objects, based on a reflection mechanism available since PHP 8.1. Previously Doctrine generated a dedicated proxy class on the filesystem for every entity with lazy-loaded relations, inheriting from the actual entity class and forwarding method by method. These generated classes had to be regenerated on every entity change and were a frequent source of caching problems in production.

Lazy Ghost Objects dispense with generated code entirely. Instead, PHP itself creates an instance of the real entity class at runtime, whose properties are only initialized on first access. For application code almost nothing changes: instanceof checks now work more reliably, because a Lazy Ghost Object really is an instance of the entity class, instead of a separate proxy class that merely imitates it.


<?php
declare(strict_types=1);

// Doctrine ORM 2.x: instanceof against a generated proxy class often failed
// get_class($order) returned "Proxies\__CG__\App\Entity\Order"
if ($order instanceof Order) {
    // worked in most, but not all, cases due to proxy inheritance quirks
}

// Doctrine ORM 3.x: Lazy Ghost Objects are real instances of the entity class
$order = $entityManager->getReference(Order::class, 42);
var_dump($order instanceof Order);  // always true, no proxy subclass involved
var_dump(get_class($order));        // "App\Entity\Order", not a generated proxy

// Forcing initialization still works the same way
$entityManager->getUnitOfWork()->initializeObject($order);

For projects that previously checked explicitly against generated proxy class names, for example in debug output or in custom serialization logic, a targeted search for Proxies\\__CG__ across the codebase is mandatory before migrating. After moving to Doctrine ORM 3, these class names simply no longer exist, and any string comparison relying on them returns a wrong result without PHP throwing an error.

5. Typed embeddables and mapping attributes

With XML and YAML mapping gone, PHP attribute-based mapping becomes the only supported way to describe entities. For projects that had already switched to attributes, little changes. For projects with historically grown XML mapping, the full conversion is the most labor-intensive part of the entire migration, since every mapping file must be translated to attributes, either manually or with a conversion script.

Embeddables, meaning value objects mapped as part of an entity's table, get stricter type checking in Doctrine ORM 3: an embeddable's constructor arguments must match the mapped properties exactly, and implicit type conversions that were still tolerated in ORM 2.x now trigger an explicit mapping exception at container compile time.


<?php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Embeddable]
final readonly class Money
{
    public function __construct(
        #[ORM\Column(type: 'integer')]
        public int $amountCents,

        #[ORM\Column(type: 'string', length: 3)]
        public string $currency,
    ) {
    }
}

#[ORM\Entity]
class Invoice
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Embedded(class: Money::class)]
    private Money $total;

    public function __construct(Money $total)
    {
        $this->total = $total;
    }
}

The benefit of this strictness shows up mainly during refactoring: if a property in an embeddable is renamed or its type changed, Doctrine ORM 3 reports the error immediately when compiling the metadata, instead of only at runtime during an actual database access. This early detection reduces the number of migration errors that only surface in production.

6. Enum support and identifier generation

Native PHP enums are directly supported as a column type in Doctrine ORM 3, without the previously required detour through a hand-written Type. An enum property is declared directly using enumType in the #[ORM\Column] attribute, and Doctrine automatically handles the conversion between the database value and the PHP enum instance.


<?php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}

#[ORM\Entity]
class Order
{
    #[ORM\Column(enumType: OrderStatus::class)]
    private OrderStatus $status = OrderStatus::Pending;

    public function markAsShipped(): void
    {
        $this->status = OrderStatus::Shipped;
    }
}

Identifier generation support for database-specific sequence strategies has been unified, so SEQUENCE and IDENTITY now behave more consistently across different database drivers than in Doctrine ORM 2.x, where behavior partly differed by driver. Projects with composite identifiers should specifically check before migrating whether their custom IdGenerator implementations are still compatible with the unit of work's new internal structure.

7. Migration strategy: Composer, tests, staging

The safest strategy for migrating from Doctrine ORM 2 to 3 does not start with the Composer update itself, but with a full inventory: composer why-not doctrine/orm ^3.0 shows blocking dependencies, a project-wide search for *.orm.xml and *.orm.yml shows the scope of the required mapping conversion, and a search for ->merge( as well as Proxies\\__CG__ reveals places depending on removed APIs.

Only after this inventory does the actual Composer update follow, in a separate branch, followed by a complete test run. Functional tests that actually run against a database matter more here than pure unit tests, because many breaking changes only become visible when actually persisting and loading entities. A staging deployment with production-like data before the final rollout also reveals performance regressions that can, in isolated cases, arise from Lazy Ghost Objects' changed initialization behavior.

8. Common pitfalls with custom types and listeners

Custom Doctrine types registered via Type::addType() must implement their conversion methods with the updated signatures in Doctrine ORM 3, since the underlying AbstractPlatform class in doctrine/dbal also went through breaking changes. A common mistake: a custom type that implicitly relied on a specific platform method which was renamed in the new DBAL version only surfaces with a TypeError at runtime, not already at compile time.

Entity listeners and subscribers reacting to lifecycle events such as preFlush or postLoad should be explicitly tested against Lazy Ghost Objects after migrating, because accessing a not yet initialized property inside a postLoad listener now more reliably triggers initialization than with the old proxy classes, which in rare cases can result in additional database queries that did not occur before.

9. Doctrine ORM 2 vs. 3 side by side

The table below summarizes the key differences between Doctrine ORM 2 and 3 for migration planning.

Area Doctrine ORM 2.x Doctrine ORM 3.x Migration effort
Lazy loading Generated proxy classes Lazy Ghost Objects Low, mostly transparent
Mapping format XML, YAML, attributes Attributes only High for XML/YAML
EntityManager::merge() Available Removed Medium, manual replacement needed
Native enum support Only via custom type Directly in the column attribute Low, optional simplification

The single largest item in almost every migration is converting XML or YAML mapping to attributes, while the Lazy Ghost switch remains largely transparent for most projects, as long as no explicit checks against generated proxy class names exist in the code.

Mironsoft

Doctrine and Symfony migrations without production downtime

Ready to migrate to Doctrine ORM 3?

We analyze your mapping, convert XML and YAML definitions to attributes, and support the full Doctrine ORM 3 rollout including staging tests and performance validation.

Mapping conversion

Fully converted from XML and YAML to PHP attributes

Breaking change audit

Search for merge(), proxy references and outdated custom types

Staging validation

Functional tests with production-like data before go-live

10. Summary

Migrating from Doctrine ORM 2 to 3 is not just a version bump, it touches central parts of every Symfony application: Lazy Ghost Objects replace generated proxy classes, PHP attributes become the only mapping format, and methods like EntityManager::merge() disappear in favor of more explicit alternatives. Native enum support and more strictly typed embeddables are the most visible improvements that justify the effort.

A successful migration starts with a complete inventory, followed by the most labor-intensive single task, mapping conversion, and ends with a staging rollout that includes functional tests against a real database. Teams that follow this order and specifically search for removed APIs like merge() and generated proxy class names can bring Doctrine ORM 3 into existing Symfony projects predictably and without production downtime.

Migrating Doctrine ORM 2 to 3 — The Essentials at a Glance

Lazy Ghost Objects

Replace generated proxy classes, real instanceof checks, mostly transparent for application code.

Attributes only

XML and YAML mapping removed, full conversion is the most labor-intensive migration step.

merge() removed

Explicit reload via find() plus manual field update replaces the old merge() method.

Native enums

enumType in the column attribute replaces hand-written custom types for PHP enums.

11. FAQ: Migrating Doctrine ORM 2 to 3

1Is a plain Composer update enough?
Usually not. Check blocking dependencies first and convert XML/YAML mapping to attributes.
2What are Lazy Ghost Objects?
PHP-native lazy-loading objects, fully replace generated proxy classes.
3Why was merge() removed?
Was a common bug source with detached entities, explicit alternative makes data flow clearer.
4Must XML mapping be rewritten manually?
Manually or partly automated with Rector rules, a fully automatic solution does not exist.
5Do custom Doctrine types keep working?
Not guaranteed, test against the new AbstractPlatform signatures from doctrine/dbal.
6Does instanceof behavior change?
Yes, positively. Lazy Ghost Objects are real entity instances, instanceof works more reliably.
7How do I find affected code?
Project-wide search for ->merge(, clear() with an argument, and Proxies\__CG__.
8Does the migration need downtime?
Not necessarily, affects application code not the schema. Staging test first minimizes risk.
9Does Doctrine ORM 3 bring performance gains?
Lazy Ghost Objects reduce memory usage, noticeable with many lazy-loaded relations.
10Can ORM 2 and 3 run side by side?
Not practical within the same project, use a feature branch with staging validation instead.