Doctrine Annotations to Attributes: Migrating Entity Mapping Before You Can Upgrade

#doctrine annotations to attributes

Founder & Lead Developer

Expert in software development and legacy code optimization

LinkedIn

If your Symfony application still maps entities with @ORM\Column and @ORM\ManyToOne style comments, you cannot upgrade to Doctrine ORM 3 without first moving to doctrine annotations to attributes conversion. ORM 3 removed the annotation driver entirely, and Symfony 7 dropped the doctrine/annotations package from its default dependencies. The mapping format that every Symfony project used from roughly 2010 through 2022 is no longer an option going forward, which makes this migration a prerequisite rather than a nice-to-have cleanup task.

The good news is that most of the work is mechanical. The bad news is that "most" is not "all," and the parts that are not mechanical are exactly the parts that break production if you rush them.

Why this stopped being optional

Doctrine deprecated annotation mapping in version 2.x releases for years, and most teams treated the deprecation notice the way most teams treat deprecation notices: as a problem for next quarter. Doctrine ORM 3 removed the doctrine/annotations driver from its dependency tree, so a project pinned to ORM 2 can keep running annotations, but anything moving to ORM 3 has to map with native PHP 8 attributes instead.

Symfony 7 made the same call independently. doctrine/annotations is no longer required by symfony/framework-bundle, and new Symfony skeletons ship without it. If your composer.json still lists it as a direct dependency, that is usually a project that has not touched its entity mapping strategy since Symfony 4 or 5.

This matters most for the Symfony upgrade path we described in our guide to upgrading to Symfony 7: you cannot complete that upgrade, or the parallel move to Doctrine ORM 3, while annotation mapping is still in the codebase. The conversion has to happen first, as its own pull request, before the framework upgrade lands.

Doctrine annotations to attributes: what changes in the mapping

PHP attributes and Doctrine annotations describe the same metadata. The syntax is different, and so is how PHP reads it.

An annotation lives inside a docblock comment:

/**
 * @ORM\Entity(repositoryClass=InvoiceRepository::class)
 * @ORM\Table(name="invoices")
 */
class Invoice
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private int $id;

    /**
     * @ORM\ManyToOne(targetEntity=Client::class, inversedBy="invoices")
     * @ORM\JoinColumn(nullable=false)
     */
    private Client $client;
}

An attribute is a native PHP language construct, parsed by the engine itself rather than by a docblock reader:

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

    #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'invoices')]
    #[ORM\JoinColumn(nullable: false)]
    private Client $client;
}

Two differences cause most of the real bugs in this migration. First, annotation arguments use =, while attribute arguments use :, and copying code by hand without noticing the difference produces a parse error that your IDE may not flag until runtime. Second, attributes are validated by the PHP parser at compile time, so a typo in an attribute argument name fails loudly, while the equivalent typo in an annotation docblock often fails silently because Doctrine's annotation reader is more forgiving about unrecognized keys.

Running Rector for the mechanical conversion

Rector has a dedicated rule set for exactly this migration, and for a typical entity with standard Doctrine mappings it gets you most of the way there automatically. We covered the general workflow in our guide to Rector for legacy PHP, and the Doctrine-specific configuration is a short addition to that setup:

use Rector\Doctrine\Set\DoctrineSetList;

$rectorConfig->sets([
    DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,
]);

Running this against a real entity directory, Rector correctly converts @ORM\Column, @ORM\Id, @ORM\GeneratedValue, the relationship mappings (@ORM\ManyToOne, @ORM\OneToMany, @ORM\ManyToMany), index and unique constraint definitions, and lifecycle callbacks. Across the mid-size Symfony codebases we work on, the large majority of mapping lines convert cleanly without a manual touch, because standard column and relationship mappings follow a small, predictable set of patterns that a rule-based tool handles well.

Always run it with --dry-run first and review the diff entity by entity rather than trusting a green pipeline. Rector will not break on code it does not understand, but it will sometimes leave a mix of old and new syntax in a file if a docblock contains something it cannot parse cleanly, and a mixed file is worse than an unconverted one because it looks finished.

Where Rector cannot help

The remaining 15% tends to cluster in three places.

Custom or project-specific annotations are the most common gap. If your team wrote its own annotation classes for domain concepts, such as an @Auditable marker or a @SoftDeletable trait annotation, Rector's Doctrine rule set has no idea those exist and will leave them untouched. You have to write the attribute equivalent by hand, including the attribute class itself if one does not already exist, and then update every entity that uses the old annotation.

Validator and serializer groups are the second gap, and the one most likely to cause a production bug that nobody notices until a form validation silently stops working. Symfony's validation constraints and serializer groups both support annotation syntax (@Assert\NotBlank, @Groups({"invoice:read"})), and both have attribute equivalents (#[Assert\NotBlank], #[Groups(['invoice:read'])]). Rector's Doctrine rule set does not touch these unless you also enable the Symfony-specific rule sets for validator and serializer attributes, and even then, grouped annotations with multiple group names in a single docblock are worth checking by hand, since the array syntax change is an easy place to drop a group silently.

Mapped superclasses and trait-based mapping are the third. If entities share column definitions through a trait or an @ORM\MappedSuperclass, Rector processes each file independently and can produce a correct attribute conversion in the trait while missing that the trait's docblock also carried class-level annotations that need to move to the class using the trait, not the trait itself.

The XML mapping alternative

Doctrine still supports XML mapping files as a third option alongside annotations and attributes, and for a specific kind of project this is worth considering instead of a full attribute conversion. If your entities are generated or heavily templated, for example from a database-first scaffold, keeping the mapping in XML separates metadata from code entirely and avoids touching PHP classes at all during the migration.

For most hand-written Symfony applications, this is the wrong trade. XML mapping means your entity classes no longer tell you their own mapping, which makes code review and IDE navigation worse, and almost nobody chooses it going forward. We mention it mainly because teams sometimes propose it as a way to "avoid" the migration, and it is worth knowing upfront that it trades one maintenance burden for a different one rather than removing the work.

Verifying the result

Doctrine ships a built-in check for exactly this kind of change:

php bin/console doctrine:schema:validate

This compares your mapping metadata against the actual database schema and against Doctrine's internal consistency rules, and it will catch a mismatched column type, a missing inverse side on a relationship, or a mapping that no longer resolves to a valid class. Run it before the migration as a baseline, then again after each batch of conversions, so a problem introduced by the conversion is isolated to the entities you touched in that batch rather than lost in a pile of changes.

It is not a complete substitute for your test suite. doctrine:schema:validate checks structural consistency, not application behavior, so a validation group that silently dropped a constraint will pass schema validation while still breaking form validation at runtime. Run your existing functional test suite against the converted entities as well, particularly anything that exercises serialization or form submission.

Doing it in small pull requests

The failure mode we see most often in this migration is not a bad conversion. It is one enormous pull request covering 150 entities that is too large for anyone to review properly, so it gets approved on trust and merged, and the validation group bug surfaces three weeks later in production with no clear diff to point to.

Split the work by domain area rather than by file count: invoicing entities in one pull request, user and auth entities in another, catalog entities in a third. Run Rector on each batch separately, run doctrine:schema:validate after each batch, and manually review the validator and serializer annotations in that batch specifically, since those are the ones Rector will not have converted for you. A reviewer who can actually read the whole diff in one sitting will catch the dropped group name that a rubber-stamped 4,000-line diff never will.

This migration has no deadline enforced by a running application, since annotation mapping keeps working on ORM 2 and older Symfony versions indefinitely. The deadline is set by whichever upgrade you want to do next. Doing the attribute conversion as deliberate, reviewable batches now is considerably cheaper than discovering it is blocking your Symfony 7 or Doctrine ORM 3 upgrade later, under more time pressure and with less context on why a given annotation was written the way it was.

If you are staring down this conversion on a large legacy codebase and want a second pair of eyes on the validator and serializer edge cases before you merge, reach out to Wolf-Tech. We do this kind of migration as part of our legacy code modernization work, and we would rather review your batch plan before the pull requests go out than debug a dropped validation group after they do. More on how we work at wolf-tech.io.