KwikTech Symfony · API Platform · Doctrine upgrades
Upgrade guide · 14 min read

Upgrading Symfony 5.4 to 6.4: the complete guide

Step-by-step: PHP 8.1, deprecations to zero, the new security system instead of Guard, Swiftmailer to Mailer, annotations to attributes, and the 6.4 composer update itself.

Published 2026-09-12. Written for teams doing the upgrade themselves; if you would rather have it done, see fixed-price upgrades. Also available in Deutsch.

Why 5.4 to 6.4 is the big one

Symfony 6.0 removed everything 5.4 deprecated, and 5.x deprecated a lot: the Guard security system, the old session service, Swiftmailer integration, most of what SensioFrameworkExtraBundle did, and annotation-based configuration in favour of PHP 8 attributes. 6.4 is the last 6.x release and the only one still receiving fixes, so the target is always 6.4, never 6.0. Bug fixes for 6.4 end in November 2026, which is why most teams continue straight to 7.4 afterwards.

The good news: everything below can be done on 5.4, in separate pull requests, each deployable. The actual version bump is the last and smallest step.

Step 0: prerequisites

  • PHP 8.1 or newer. Symfony 6.4 requires 8.1. Do this first, on 5.4, with your current dependencies. 5.4 runs on PHP 8.1 through 8.3 without changes.
  • Symfony 5.4.x latest patch, with symfony/flex 2.x and Composer 2.
  • A test suite that runs. Even a thin one. Add symfony/phpunit-bridge if it is missing; you need its deprecation report.
  • Rector as a dev dependency: composer require --dev rector/rector.

Step 1: make the deprecation report readable

The PHPUnit bridge prints every deprecation triggered during the test run, grouped by message. Configure it to fail on deprecations from your own code so the number can only go down:

# phpunit.xml.dist
<php>
    <env name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0"/>
</php>

Run the suite and read the "Remaining direct deprecation notices" section. That list is the work. Typical entries on a 5.4 codebase:

  • "Since symfony/security-guard 5.3: The Guard component is deprecated" → step 3.
  • "The 'session' service is deprecated, use RequestStack" → inject RequestStack and call getSession().
  • "Method X() will return Y in 6.0, not returning Y is deprecated" → step 5.
  • Annotation-based routing, SensioFrameworkExtra annotations → step 4.

Step 2: run the Rector 5.4 and 6.x sets on 5.4

Rector fixes the mechanical part: renamed classes, new signatures, getSession(), form option renames, event names. Start with the sets up to 6.4 while still running 5.4; the resulting code is compatible with both.

// rector.php
use Rector\Config\RectorConfig;
use Rector\Symfony\Set\SymfonySetList;

return RectorConfig::configure()
    ->withPaths([__DIR__.'/src', __DIR__.'/tests'])
    ->withSets([
        SymfonySetList::SYMFONY_54,
        SymfonySetList::SYMFONY_60,
        SymfonySetList::SYMFONY_61,
        SymfonySetList::SYMFONY_62,
        SymfonySetList::SYMFONY_63,
        SymfonySetList::SYMFONY_64,
        SymfonySetList::SYMFONY_CODE_QUALITY,
    ])
    ->withSymfonyContainerXml(__DIR__.'/var/cache/dev/App_KernelDevDebugContainer.xml');
vendor/bin/rector process --dry-run   # review
vendor/bin/rector process             # apply, then run the tests

Commit this as its own PR. Do not mix it with hand-written changes; reviewing a Rector diff is a different activity from reviewing a refactor.

Step 3: replace Guard with the new authenticator system

Symfony 5.4 ships the authenticator-based security system as an opt-in. Turn it on and rewrite every Guard authenticator on 5.4, so that by the time you bump to 6.4 security is already done.

# config/packages/security.yaml
security:
    enable_authenticator_manager: true   # 5.4 only; the key disappears in 6.0
    firewalls:
        main:
            lazy: true
            custom_authenticator: App\Security\ApiKeyAuthenticator
            # 'anonymous: true' is gone; 'lazy: true' replaces it
// Before: Guard
class ApiKeyAuthenticator extends AbstractGuardAuthenticator
{
    public function getCredentials(Request $request) { return $request->headers->get('X-API-KEY'); }
    public function getUser($credentials, UserProviderInterface $userProvider) { /* ... */ }
    public function checkCredentials($credentials, UserInterface $user) { return true; }
    // supports(), onAuthenticationSuccess(), onAuthenticationFailure(), start(), supportsRememberMe()
}

// After: authenticator + passport
class ApiKeyAuthenticator extends AbstractAuthenticator
{
    public function supports(Request $request): ?bool
    {
        return $request->headers->has('X-API-KEY');
    }

    public function authenticate(Request $request): Passport
    {
        $key = (string) $request->headers->get('X-API-KEY');

        return new SelfValidatingPassport(new UserBadge($key, fn (string $key) => $this->users->findByApiKey($key)));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null; // continue the request
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $e): ?Response
    {
        return new JsonResponse(['error' => $e->getMessageKey()], 401);
    }
}

Form logins usually do not need a custom class at all any more: form_login: in the firewall plus make:security:form-login covers it. Passwords: encoders: became password_hashers:, and UserPasswordEncoderInterface became UserPasswordHasherInterface (Rector renames these).

Step 4: annotations to attributes, SensioFrameworkExtra out

SensioFrameworkExtraBundle is abandoned and does not install on Symfony 7. Its features live in core now. On 5.4 with PHP 8.1 you can switch to attributes for routing immediately:

# config/routes/annotations.yaml (5.4 name) → on 6.1+ the type is 'attribute'
controllers:
    resource: ../../src/Controller/
    type: annotation      # keeps working in 6.4; rename to 'attribute' when you get there
// Before
/**
 * @Route("/orders/{id}", name="order_show", methods={"GET"})
 * @ParamConverter("order", class="App\Entity\Order")
 * @IsGranted("ROLE_SALES")
 * @Template()
 */
public function show(Order $order) { return ['order' => $order]; }

// After (Symfony 6.2+ attributes; ParamConverter → MapEntity, Template → explicit render)
#[Route('/orders/{id}', name: 'order_show', methods: ['GET'])]
#[IsGranted('ROLE_SALES')]
public function show(#[MapEntity] Order $order): Response
{
    return $this->render('order/show.html.twig', ['order' => $order]);
}

Rector's SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES and DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES handle routes, Doctrine mapping, validation constraints and serializer groups. #[MapEntity] and #[IsGranted] exist from 6.2, so if you must stay on 5.4 for a while, keep SensioFrameworkExtra 6.2 (which forwards to the core attributes) and remove it right after the bump. Details per bundle are in the abandoned bundles guide.

Step 5: return types

Symfony 6 added native return types to most interfaces and abstract methods. Your classes that implement them (EventSubscriberInterface::getSubscribedEvents(), AbstractType::configureOptions(), voters, normalizers, Twig extensions) must declare the same return type or PHP fatals. Symfony ships a tool that patches them in bulk:

SYMFONY_PATCH_TYPE_DECLARATIONS='force=2&php=8.1' vendor/bin/patch-type-declarations

Run it on 5.4, review the diff, run the tests, commit. This is the change that catches teams who skip deprecation hunting: the fatal only appears after the bump.

Step 6: Swiftmailer to Mailer

If symfony/swiftmailer-bundle is in your composer.lock, replace it on 5.4. The Mailer component has been stable since 4.4.

// Before
$message = (new \Swift_Message('Welcome'))->setFrom('noreply@example.com')->setTo($user->getEmail())->setBody($html, 'text/html');
$this->mailer->send($message);

// After
$email = (new TemplatedEmail())
    ->from('noreply@example.com')
    ->to($user->getEmail())
    ->subject('Welcome')
    ->htmlTemplate('emails/welcome.html.twig')
    ->context(['user' => $user]);
$this->mailer->send($email); // MailerInterface

Transports move to MAILER_DSN. Spool becomes Messenger: route SendEmailMessage to an async transport and the behaviour is the same.

Step 7: the bump itself

With deprecations at zero, security on authenticators, attributes in place and return types patched, the version change is a composer exercise:

composer config extra.symfony.require "6.4.*"
composer update "symfony/*" --with-all-dependencies
composer recipes:update     # walks you through changed Flex recipes, one at a time

Expect these follow-ups:

  • enable_authenticator_manager must be removed from security.yaml (unknown key in 6.x).
  • Routing: type: annotation → type: attribute, then remove doctrine/annotations if nothing else needs it.
  • Bundles that capped at ^5.4 need their next major. The compatibility pages show which release of each bundle first allowed 6.4.
  • symfony/security-guard, symfony/swiftmailer-bundle, sensio/framework-extra-bundle come out of composer.json.

Run the tests, run bin/console lint:container, lint:twig templates, lint:yaml config and doctrine:schema:validate. Then deploy 6.4 with nothing else in the release.

Order of the pull requests

  1. PHP 8.1 (or 8.2) on 5.4
  2. Rector 5.4–6.4 sets
  3. Guard → authenticators (opt-in on 5.4)
  4. Annotations → attributes, SensioFrameworkExtra replaced
  5. Return types patched
  6. Swiftmailer → Mailer
  7. Deprecations to zero (whatever the report still lists)
  8. Composer bump to 6.4, recipes, bundle majors

If API Platform 2.x is in the project, insert the 2.6 → 2.7 migration before the bump: API Platform 3 needs Symfony 6.4, but 2.7 runs on both and lets you do the provider/processor work while still on 5.4. Doctrine ORM 3 can come before or after; see the ORM guide.

Skip the guesswork about which bundles block 6.4

The scan lists every package in your composer.lock whose installed release caps Symfony below 6.4, whether a newer release lifts the cap, and the cliffs (Guard, annotations, Swiftmailer, API Platform 2) it found.

Scan my project Request an assessment (€750)

More guides