Symfony 5.4 and 6.0 shipped on the twenty-ninth of November with identical features. 6.0 is 5.4 with the deprecated code removed and PHP 8.0 required, which means the upgrade is not a migration to 6.0 — it is a cleanup against 5.4, followed by a version bump that should be boring.
The symptom
$ grep -c 'User Deprecated' var/log/dev.log
4102
$ grep -oP 'Since symfony/K[a-z-]+ [0-9.]+' var/log/dev.log
| sort | uniq -c | sort -rn | head -5
1841 framework-bundle 5.1
902 security-bundle 5.3
611 doctrine-bridge 5.2
404 http-kernel 5.3
188 console 5.4
# 4,102 lines, in a file nobody opens, describing every
# change 6.0 will make.The deprecation log is a complete, precise list of the work required, generated automatically, and had been ignored for three minor versions. That is the normal state of a Symfony application and it is why the upgrade looks large.
Why it happens
Deprecations are warnings in a log that nothing fails on, so there is never a moment when they must be addressed. They accumulate at a steady rate across five minor versions and become visible on the day the major arrives.
The fix
Making deprecations fail
<!-- phpunit.xml.dist -->
<php>
<server name="SYMFONY_DEPRECATIONS_HELPER"
value="max[total]=0&max[self]=0&max[direct]=0"/>
</php>
<!-- and the transitional form, while working through them:
a baseline, so new ones fail and existing ones do not -->
<server name="SYMFONY_DEPRECATIONS_HELPER"
value="baselineFile=./tests/deprecations.json"/>
The baseline is the mechanism that makes this tractable: generate it once, and any deprecation not in it fails the build. That stops the count growing while the existing four thousand are worked through, which is the same shape as a static analysis baseline and has the same failure mode — regenerating it to make the build pass.
$ SYMFONY_DEPRECATIONS_HELPER='baselineFile=./tests/deprecations.json'
vendor/bin/phpunit --update-baseline
$ jq 'length' tests/deprecations.json
88 # distinct deprecations, not 4,102 occurrences
# 88 is a list somebody can work through. 4,102 is not.Eighty-eight distinct deprecations behind four thousand occurrences is the number that changed the conversation. Most of the log was one deprecation in a hot path repeated hundreds of times per test run.
Annotations to attributes
// 5.x, with the annotations library
/**
* @Route("/orders/{id}", methods={"GET"}, name="orders_show")
* @IsGranted("ORDER_VIEW", subject="order")
*/
public function show(Order $order): Response {}
// 6.0 — attributes, and the annotations library is no
// longer a dependency
#[Route('/orders/{id}', methods: ['GET'], name: 'orders_show')]
#[IsGranted('ORDER_VIEW', subject: 'order')]
public function show(Order $order): Response {}
This is a mechanical conversion with a tool that does it reliably, and it is the largest diff in the upgrade — every controller, every entity, every validation constraint. Running it as its own commit with nothing else in it is what makes the review possible.
$ composer require --dev rector/rector
$ vendor/bin/rector process src --config=rector-annotations.php --dry-run
412 files would be changed
$ vendor/bin/rector process src --config=rector-annotations.php
$ git diff --stat | tail -1
412 files changed, 1841 insertions(+), 2204 deletions(-)
# and the check that nothing moved: the route table
$ diff <(git stash && bin/console debug:router --raw)
<(git stash pop && bin/console debug:router --raw)Diffing the route table before and after is the assertion that the conversion preserved behaviour, and it is a stronger check than the test suite for this particular change. The same technique works for the container: debug:container output before and after should be identical.
The security system, which is a real change
# 5.3 introduced a rewritten authenticator system and
# deprecated the old one. 6.0 removes it.
security:
enable_authenticator_manager: true # 5.3+, opt-in
firewalls:
main:
lazy: true
custom_authenticators:
- AppSecurityApiTokenAuthenticator
# in 6.0 the flag is gone and the behaviour is the default.
# so: enable it on 5.4, fix what breaks, THEN upgrade.
This is the one part of the upgrade that is a genuine rewrite rather than a rename — a custom authenticator implements a different interface with different lifecycle methods. Doing it on 5.4 behind the opt-in flag means it can be tested and reverted with a configuration change.
The old and new systems cannot both be active, so this is a single cutover per firewall rather than a gradual migration. On an application with one firewall that is an afternoon; on one with four it is a week and they can be done separately.
The version bump, which should be boring
$ composer why-not symfony/framework-bundle 6.0
some/bundle 2.4.1 requires symfony/framework-bundle (^4.4|^5.0)
# the actual constraint on the timeline, and the reason to
# run this FIRST rather than last.
$ composer require symfony/framework-bundle:^6.0 --with-all-dependencies
$ vendor/bin/phpunit
Tests: 1,412 passed
$ grep -c 'User Deprecated' var/log/dev.log
0With the deprecations cleared the version bump is a composer require and a green suite, which is the promise the two-release cadence makes. The bundles that have not released a 6.0-compatible version are the real constraint and the only thing that cannot be worked around.
composer why-not is the most useful command of the whole upgrade and should be run before any planning rather than after the work. It answers “when can we do this” in one line, and the answer is usually a date somebody else controls.
Verifying it worked
$ bin/console --version
Symfony 6.0.1 (env: prod, debug: false)
$ SYMFONY_DEPRECATIONS_HELPER='max[total]=0' vendor/bin/phpunit
Tests: 1,412 passed
$ diff <(cat /tmp/router-before.txt) <(bin/console debug:router --raw)
$ diff <(cat /tmp/container-before.txt) <(bin/console debug:container --raw)
$ composer show symfony/* | grep -c '^symfony/'
41 # all on 6.0.xA zero-tolerance deprecation setting after the upgrade is what keeps the next major from being another four thousand lines. It fails on the first new deprecation, which is when it is cheapest to fix.
What this costs
LTS is two years and 6.x moves every six months, so choosing 6.0 over 5.4 is choosing a faster cadence. On a team that will keep up that is the better position; on one that upgrades once every two years, 5.4 LTS is the honest choice and it expires in November 2025 regardless.
The bundles are also the recurring constraint rather than a one-off. A dependency that lags one major behind puts a ceiling on every future upgrade, and the only real answers are to replace it, to vendor it, or to contribute the compatibility work — and the third is the one nobody budgets for.