We merged the last of it on 14 January. The number I keep coming back to is 214: that is how many distinct deprecation messages the platform produced in one week of staging traffic once we had landed on Symfony 5.4. Not 214 log lines. 214 distinct messages, sitting inside roughly 61,000 lines in Monolog’s deprecation channel. Clearing those took four of us most of December and half of January. The jump from 5.4 to 6.0 afterwards took one afternoon and a composer update.
That ratio is not luck. It is what the release process is designed to produce, and when a 6.0 upgrade turns into a disaster it is usually because the 5.4 step got skipped.
5.4 and 6.0 shipped with the same features
The release process document is blunt about it. When a major is coming, Symfony develops two versions at the same time: the new major and the last minor of the old branch. Both have the same features. The difference is that the old one still carries every deprecated class and the new one has deleted them. So the supported route is to get onto 5.4, run the app until the deprecation notices stop appearing, then change your version constraints and expect nothing to happen.
We were on 4.4 LTS, where we had been sitting since 2019. Clearing the 4.4 deprecations, jumping straight to 5.4 rather than walking through 5.0, then clearing the 5.x deprecations and bumping to 6.0 took about six weeks of calendar time with four people on it out of a team of 22. Everyone else kept shipping against the 5.4 branch until the last week.
Symfony 6.0 came out in November 2021 and requires PHP 8.0.2 or higher. We had done the 7.4 to 8.0 migration the year before, so the floor cost us nothing. If you are still on 7.4 it is two migrations stacked on top of each other, and I would not attempt them in the same quarter. The other thing on that page worth reading before you commit: 6.0 is a standard release, supported until January 2023. 5.4 is the LTS, with three years of bug fixes and four years of security fixes.
Where the 214 came from
Roughly 130 of the 214 were the same category: native return types. Symfony 6 added return type declarations to almost every method, and PHP will not let a child class omit a return type that the parent declares. The upgrade guide ships a script for exactly this, and it is the single most useful thing in the whole upgrade:
composer dump-autoload -o
SYMFONY_PATCH_TYPE_DECLARATIONS="force=2&php=8.0" ./vendor/bin/patch-type-declarations
It rewrote 380-odd files in one pass. The docs warn that the script does not care about code style, and they are right: the diff was unreadable until we ran PHP CS Fixer over it, and we ended up committing the patch and the style fix separately so reviewers could look at the second one and ignore the first.
The rest broke down roughly like this:
- Security, about 40 messages, all of which turned into real work (below).
- Config and DI signature changes:
setDeprecated()becamesetDeprecation(string $package, string $version, string $message)onDefinition,Alias,BaseNodeandNodeDefinition. Mechanical, about an hour. - Services going private:
form.factory,translator,serializer,validator,profiler,twig. We had four places pulling these out of the container by name in legacy code from the 2.x days. Constructor injection, done. - Third-party bundles, about 20 messages, none of which were ours to fix.
For that last group, composer why-not (an alias of prohibits) is the tool. composer why-not symfony/security-bundle 6.0 names every installed package whose constraints block the upgrade, which is faster than reading a 900-line dependency error. Two of our bundles had no 6.0-compatible tag yet; we forked one and dropped the other.
For counting, we leaned on two things. In dev the profiler toolbar shows a deprecation counter per request, which is good for spot checks but useless for aggregate numbers. The real number came from the deprecation Monolog channel, which the standard recipe wires up as its own handler, so a week of staging traffic gives you a file you can sort and count. In CI we used symfony/phpunit-bridge, which prints a summary at the end of the run, with SYMFONY_DEPRECATIONS_HELPER=max[total]=999999 in phpunit.xml.dist while the number was still high and a real threshold once it was low. We moved CI to GitHub Actions in the same stretch, mostly so we could run the suite against 8.0 and 8.1 in a matrix without babysitting runners.
The security rewrite was most of the work
If you read one file before starting, make it UPGRADE-6.0.md, and read the Security section twice. The old authentication system is gone: AuthenticationProviderManager, DaoAuthenticationProvider, every security.authentication.listener.* service, AnonymousToken, and the entire Guard component. Not deprecated. Removed.
The tell is immediate. Boot 6.0 with the old config and you get:
InvalidConfigurationException:
"security.enable_authenticator_manager" must be set to "true".
That option is a one-line switch in security.yaml, but flipping it on 5.4 is what surfaces the actual work, which is every custom authenticator you own. We had six: partner API tokens, a staff login form, an HMAC-signed webhook receiver for courier status callbacks, and three internal ones. Here is the partner token authenticator as it looked on 5.4, trimmed:
use SymfonyComponentSecurityGuardAbstractGuardAuthenticator;
class ApiTokenAuthenticator extends AbstractGuardAuthenticator
{
public function supports(Request $request)
{
return $request->headers->has('X-Partner-Token');
}
public function getCredentials(Request $request)
{
return ['token' => $request->headers->get('X-Partner-Token')];
}
public function getUser($credentials, UserProviderInterface $userProvider)
{
return $this->partners->findOneByApiToken($credentials['token']);
}
public function checkCredentials($credentials, UserInterface $user)
{
return true;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey)
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
return new JsonResponse(['error' => 'invalid_token'], 401);
}
public function start(Request $request, AuthenticationException $authException = null)
{
return new JsonResponse(['error' => 'auth_required'], 401);
}
public function supportsRememberMe()
{
return false;
}
}
Nine methods, and the credentials travel between three of them as an untyped array. The 6.0 version collapses the middle of that into one method returning a Passport:
use SymfonyComponentSecurityHttpAuthenticatorAbstractAuthenticator;
use SymfonyComponentSecurityHttpAuthenticatorPassportBadgeUserBadge;
use SymfonyComponentSecurityHttpAuthenticatorPassportPassport;
use SymfonyComponentSecurityHttpAuthenticatorPassportSelfValidatingPassport;
class ApiTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(private PartnerRepository $partners)
{
}
public function supports(Request $request): ?bool
{
return $request->headers->has('X-Partner-Token');
}
public function authenticate(Request $request): Passport
{
$token = $request->headers->get('X-Partner-Token');
if (null === $token) {
throw new CustomUserMessageAuthenticationException('No API token provided');
}
return new SelfValidatingPassport(
new UserBadge($token, fn (string $t) => $this->partners->findOneByApiToken($t))
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => 'invalid_token'], Response::HTTP_UNAUTHORIZED);
}
}
I like the result. I did not like getting there. Six authenticators, plus the user entity, came to about nine working days for two people. The entity changes are their own little pile: UserInterface::getUsername() is replaced by getUserIdentifier(), getPassword() and getSalt() are no longer on UserInterface at all and move to PasswordAuthenticatedUserInterface, and getRoles() now declares : array. Miss the password interface and the hasher throws a TypeError rather than anything helpful.
Then there is config. IS_AUTHENTICATED_ANONYMOUSLY is gone in favour of PUBLIC_ACCESS, because anonymous does not exist any more:
access_control:
- - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
+ - { path: ^/login, roles: PUBLIC_ACCESS }
The is_anonymous() expression function and AuthenticationTrustResolverInterface::isAnonymous() went with it. We had two Twig templates and one voter relying on those.
Removals that cost us real time
The genuinely annoying one was not in the security section. On the Monday after the 6.0 deploy, partner integrations started getting 400s from an endpoint nobody had edited in a year:
SymfonyComponentHttpFoundationExceptionBadRequestException:
Input value "status" contains a non-scalar value.
The endpoint is GET /api/v2/shipments and a handful of partners call it as ?status[]=in_transit&status[]=for_pickup. Our controller did $request->query->get('status') and treated the result as an array, which worked for years. In 6.0, InputBag::get() throws on any non-scalar value. The fix is one character short of trivial, $request->query->all('status'), and we shipped it in 40 minutes. What bothers me is why we did not catch it: our staging traffic replay never used the array form of that parameter, so the deprecation never fired, so it never appeared among the 214. Deprecation counts only cover code paths you actually execute.
The second one was quieter and worse. YAML no longer parses numbers starting with 0 as octal; you have to write 0o755. We had a parameter upload_dir_mode: 0775 feeding a chmod() call on the customs-document upload directory. After the upgrade the parser handed us the string "0775", PHP coerced it, and the directory came out with permissions that were not what anyone intended. Nothing threw. We found it two days later because a partner could not read back a file they had just uploaded.
A few more from UPGRADE-6.0.md that each cost an hour or two. The Messenger transports are no longer bundled: AMQP, Doctrine and Redis each moved to their own package, so composer require symfony/doctrine-messenger or your workers stop resolving their DSN. The session service and the SessionInterface alias are gone; you get the session from Request::getSession() or RequestStack::getSession(). RequestStack::getMasterRequest() is now getMainRequest(), and HttpKernelInterface::MASTER_REQUEST is MAIN_REQUEST. MicroKernelTrait::configureRoutes() is always called with a RoutingConfigurator. And the Doctrine bridge in 6.0 conflicts with doctrine/orm below 2.7.4 and doctrine/dbal below 2.13.1, which for us meant a DBAL 2 to DBAL 3 move we had been putting off.
Worth saying: plenty of what people call “Symfony 6 features” arrived during 5.x and are simply the default now. The Rate Limiter component landed in 5.2 and stopped being experimental in 5.4. Uid arrived in 5.1. symfony/runtime, which is why public/index.php is now a closure returning the kernel, came in 5.3. #[AsEventListener] came in 5.3. If you are on 5.4 you already have all of it.
What the 8.1 bump added
We moved to PHP 8.1 (released 25 November 2021) in the same window, partly because doing two runtime changes in one deploy window seemed less bad than two windows. Enums were the payoff. Our shipment status had been a class of thirteen string constants with a hand-written validator; it is now a backed enum and the validator is gone. We are still mapping it to a varchar column and converting at the repository boundary rather than in Doctrine, which is a compromise I expect to revisit.
readonly properties went straight into our API request DTOs. never as a return type made two redirect helpers honest. And first-class callable syntax ($this->partners->findOneByApiToken(...)) cleans up exactly the kind of user-loader closure the new authenticators are full of.
When I would stay on 5.4
If any bundle you depend on has no 6.0 tag and no maintainer answering issues, stay. If you are on PHP 7.4, stay, do the PHP migration first, and come back in six months. If you cannot give the deprecation burn-down a named owner and real hours, stay, because the burn-down is the upgrade and the version bump is a formality. 5.4 gets bug fixes for three years. There is no prize for being early.
What I would not do is stay on 4.4 and plan to go to 6.0 later in one move. The 214 messages we cleared were mostly 5.x deprecations that only become visible once you are running 5.4. Skip that and you are debugging removals with no notices to guide you.
Still open on our side: 340 controller actions using annotation routing that we are converting to attributes file by file as we touch them, and a shared internal package that still declares php: ^8.0 because one other service has not moved yet, which keeps enums out of it.
Sources
- Symfony 6.0 release page: the PHP 8.0.2 minimum, November 2021 release, and the January 2023 end of support.
- The Release Process: the dual-development rule that makes 5.4 and 6.0 feature-identical, and the LTS support windows (three years bug fixes, four years security).
- UPGRADE-6.0.md: every removal quoted here, including Guard, the authentication providers, the session service, the Messenger transport split, and the YAML octal change.
- Upgrading a Major Version: the
patch-type-declarationsscript, theSYMFONY_PATCH_TYPE_DECLARATIONSoptions, andSYMFONY_DEPRECATIONS_HELPER. - How to Write a Custom Authenticator: the
authenticate(): Passportsignature,UserBadgewith a user loader, andcustom_authenticatorsconfig. - SecurityExtension.php: the exact exception thrown when
enable_authenticator_manageris not true. - InputBag.php: the
BadRequestExceptionmessage we got in production and theget()scalar-only contract. - symfony/doctrine-bridge composer.json: the conflict rules against
doctrine/orm < 2.7.4anddoctrine/dbal < 2.13.1. - PHP 8.1 release announcement: enums, readonly properties,
never, and first-class callable syntax. - symfony/deprecation-contracts: the
trigger_deprecation()convention behind the notices you are counting.