You run Rector. It rewrites every docblock into an attribute. The diff looks perfect. You commit, deploy, and your entities are no longer recognised at all.
The code conversion is the easy part, and it is what every guide covers. The part that breaks things is a two-word change in a YAML file, and if you make it in the wrong order you get an application that cannot find its own database mapping.
Why you have to do this
Two deadlines, unrelated to each other.
Doctrine ORM 3.0 removed annotation support. Not deprecated. Removed. If you want to be on a current Doctrine, annotations are not an option.
Symfony 8 contains no deprecated code. As covered in the 6.4 to 7.4 upgrade article, the route to Symfony 8 is a deprecation cleanup performed on 7.4. Annotation usage shows up in that cleanup.
So this sits on the critical path for both upgrades, and it touches every entity, every route and every validation constraint you have. Worth doing deliberately rather than in the middle of something else.
There is more than one annotation system
Get this straight first, because it determines what you have to configure.
Three separate systems used the same @Annotation syntax, and each has its own migration:
| System | Examples | Rector set |
|---|---|---|
| Doctrine ORM mapping | @ORM\Entity, @ORM\Column | DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES |
| Symfony | @Route, @Assert\NotBlank | SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES |
| SensioFrameworkExtraBundle | @ParamConverter, @Template | SensiolabsSetList::ANNOTATIONS_TO_ATTRIBUTES |
| Third party | JMS, Gedmo, GraphQLite | configure manually |
Only the Doctrine ORM one needs a configuration change afterwards, because only that one uses a metadata driver that has to be told what to read. The others are resolved at the point of use.
The Sensio row is a special case. Those do not simply change syntax, they change to different mechanisms. @ParamConverter splits into two replacements with different behaviour, which is its own migration. Do not let Rector handle that one unsupervised.
Running Rector
composer require --dev rector/rector
<?php
use Rector\Config\RectorConfig;
use Rector\Doctrine\Set\DoctrineSetList;
use Rector\Symfony\Set\SymfonySetList;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
])
->withSets([
DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,
SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES,
]);
Dry run first, always:
vendor/bin/rector process --dry-run
Then apply, then fix the formatting. Rector produces correct code with untidy whitespace, particularly around properties that just lost a docblock:
vendor/bin/rector process
vendor/bin/php-cs-fixer fix
Make those two separate commits. A formatting pass mixed into a semantic change makes the diff unreviewable, and you do want to review this diff.
Two requirements to check before starting: doctrine/orm 2.9 or later, which is when attribute support arrived, and PHP 8.0 or later for attributes to exist at all.
The step everyone misses
Doctrine does not read your entities directly. It uses a metadata driver, and the driver is configured to read one format.
# config/packages/doctrine.yaml
doctrine:
orm:
mappings:
App:
is_bundle: false
type: annotation # <-- this line
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
Change it:
type: attribute
Until you do, Doctrine is looking for docblocks that Rector just deleted. Your entities have no mapping metadata, and depending on where it surfaces you get an empty schema, a mapping exception, or entities that simply are not found.
And it is not a mix. The annotation driver reads annotations. The attribute driver reads attributes. Switching means every entity under that mapping must already be converted. A half-converted codebase has half its entities invisible, whichever way the setting points.
That is the usual version of this failure. Someone converts a few entities by hand, or generates a new one with make:entity, which produces attributes. Now some entities have annotations and some have attributes, and no single driver setting covers both. It gets discovered when the newest entity is not recognised, which points at entirely the wrong cause.
So the order matters. Convert everything, then flip the driver, in the same commit. There is no safe intermediate state.
On Doctrine ORM 3.x the annotation driver is gone entirely, which at least removes the ambiguity.
What Rector gets wrong
Doctrine's own maintainers used Rector to migrate the doctrine/orm test suite and reported that some annotations were not perfectly migrated. Expect the same.
The documented case worth knowing is constraints with several named options. There is an open Rector issue where converting @Assert\Range with min, max and custom messages produced a break, because annotations and attributes are not perfectly interchangeable in how arguments are handled. Any constraint with multiple named options is worth reading rather than skimming.
Two more categories to check by hand:
Nested annotations. Constraints containing other constraints, such as @Assert\All or @Assert\Collection, produce nested attribute syntax that is easy to get subtly wrong.
Anything from a bundle with no Rector set. Gedmo extensions, JMS serializer annotations, anything custom. Rector can be given an explicit mapping, but by default it leaves them alone, and a codebase with annotations left over is one that still needs doctrine/annotations installed.
Verifying it worked
Three checks, cheapest first.
php bin/console doctrine:mapping:info
Every entity should be listed. A missing one means the driver cannot read it, which almost always means the conversion or the driver setting is incomplete.
php bin/console doctrine:schema:validate
This compares your mapping against the actual database. If the conversion dropped an option, such as a column length or a nullable flag, it shows up here as a schema difference.
php bin/console debug:router
For routes. A route that vanished is one whose annotation was removed without a working attribute replacing it.
Then run the test suite. Entity tests catch mapping problems the commands above will not, such as a relation quietly losing its cascade behaviour.
Finishing up
Once nothing in your codebase uses annotations, remove the library:
composer remove doctrine/annotations
If Composer refuses because something still requires it, that tells you which bundle has not migrated. Useful information rather than an obstacle, and exactly the kind of indirect dependency that blocks a Symfony major upgrade later.
composer why doctrine/annotations
Keep rector/rector installed rather than removing it. It is more useful as a permanent dev dependency, and the Symfony upgrade sets are the next thing you will want it for.
Why this is an improvement
Worth a paragraph, because a purely defensive migration is hard to prioritise.
Annotations were a workaround. PHP had no syntax for metadata, so Doctrine built a parser that read structured data out of comments. It worked, and it meant your IDE could not follow a class name, static analysis could not verify anything, and a rename tool could not touch it. Your mapping lived in a string PHP itself never validated.
Attributes are real syntax. The class name resolves through a normal use statement, so renames work, PHPStan can check arguments, and a typo is an error rather than a runtime surprise. Reading one does not require running a text parser over a docblock either.
The same shift happened in Drupal, for the same reasons and with the same tooling, which I wrote about in the plugin discovery article. This is a PHP language feature rather than a framework one, and the arguments are identical wherever it turns up.