Symfony 5 to Symfony 6: Migration Checklist
Framework upgrades can feel like moving houses—you need to pack carefully, label everything, and ensure nothing breaks in transit. Strictly speaking, though, a Symfony migration is more like renovating a house while living in it; the framework powers your application, so you can’t simply shut down operations during the transition. Symfony 6 represents a meaningful evolution over Symfony 5, bringing native PHP 8.1+ features, improved developer experience, and enhanced performance. However, the migration process requires careful planning to avoid disruption—and more importantly, to maintain your application’s reliability throughout.
In this guide, we’ll walk through the migration systematically. We’ll start by preparing your Symfony 5 application, address deprecations, perform the core upgrade, and finally test everything thoroughly. By the end, you’ll have a clear roadmap to upgrade with confidence. Let’s begin.
Phase 1: Preparation is Key
Before we touch any application code, we need to get our project into a known, stable state. Rushing into an upgrade without preparation is like building on sand—we’ll encounter surprises we’re not ready for. This phase sets us up for success by surfacing all the deprecations we’ll need to address. Though the exact process can vary depending on your application’s size, the general approach remains the same.
1. Upgrade to the Latest Symfony 5.4 Patch Version
First, we need to ensure we’re running the latest Symfony 5.4 patch release. Why the latest patch? Because Symfony’s deprecation detection works best when we’re on the most up-to-date 5.4 version—each patch release adds more deprecation notices for features that will be removed in Symfony 6.
In practice, this means updating your composer.json file. We’ll use the 5.4.* constraint to get the latest patch version automatically:
{
"require": {
"symfony/framework-bundle": "5.4.*",
"symfony/console": "5.4.*"
}
}
Once we’ve updated the version constraints, we run the following command:
$ composer update "symfony/*" --with-all-dependencies
One may wonder: why use --with-all-dependencies instead of a plain composer update? The answer is straightforward. Without this flag, Composer might refuse to update packages that depend on your Symfony packages. With this flag, Composer updates those dependent packages as well to compatible versions. This is generally what we want during a major version upgrade—it helps resolve version conflicts automatically.
What to expect: Composer will download and install Symfony 6 along with compatible versions of all related packages. The output will show which packages are being updated and to which versions. For example, you might see:
Updating symfony/framework-bundle (5.4.21 => 6.0.12)
Updating symfony/console (5.4.11 => 6.0.12)
Updating symfony/dependency-injection (5.4.11 => 6.0.12)
Of course, the exact version numbers you see will vary depending on when you run this command. You might also see some packages removed or new ones added as dependencies change.
If something goes wrong: Composer might report conflicts it can’t resolve. In that case, read the error message carefully—it usually tells you which packages are in conflict. You may need to:
- Update additional third-party bundles
- Remove packages that aren’t compatible yet
- Use
composer why-not symfony/framework-bundle 6.0to understand conflicts
Sometimes, you might need to run the update multiple times as Composer gradually resolves the dependency tree. Be patient—this can take several iterations.
Of course, a Composer update modifies your composer.lock file. That’s exactly what we want, but it means we should have our current composer.lock committed to version control before proceeding. This way, we can always roll back if needed.
Phase 3: Code and Configuration Updates
With Symfony 6 now installed, we need to address backward-compatibility breaks. This is where we adapt our code to match the new APIs and conventions. Of course, the exact changes depend on your application’s features, but we can cover the most common ones.
1. Update Your Recipes
Symfony Flex recipes define the recommended configuration for bundles. When we upgrade, recipe updates may modify our configuration files to match best practices. Let’s check what recipe updates are available:
$ composer recipes:update
This command shows which installed bundles have recipe updates. You’ll see output like this:
The following recipes have been updated:
- symfony/console (5.4 => 6.0)
- symfony/framework-bundle (5.4 => 6.0)
You can review the proposed changes and apply them selectively. Though recipes are generally safe, we should still review changes—especially if we’ve customized our configuration files. Recipes attempt three-way merges, but manual review helps ensure nothing unexpected happens.
Tip: If you’ve heavily customized your configuration files, consider backing them up before applying recipe updates. In practice, though, most recipe updates are straightforward and safe. If you’re curious about what a recipe does before applying it, you can inspect the recipe files in the vendor/symfony/flex directory.
2. Address Backward-Compatibility Breaks
Symfony 6 introduces several breaking changes. The official UPGRADE-6.0.md document is the authoritative source, but let’s cover the most impactful changes you’re likely to encounter.
Type Hinting and Return Types
Symfony 6 adds PHP 8.1+ native return types to many core methods. If you extend Symfony classes and override those methods, your child classes must match those return types exactly. This is because PHP enforces covariance/contravariance rules.
For example, if you have a custom repository extending ServiceEntityRepository and override createFindQueryBuilder(), you’ll now need to add a : ?QueryBuilder return type. Here’s what a typical change looks like:
// Before Symfony 6
class ProductRepository extends ServiceEntityRepository
{
public function createFindQueryBuilder(string $alias = 'o')
{
return parent::createFindQueryBuilder($alias);
}
}
// After Symfony 6
use Doctrine\ORM\QueryBuilder;
class ProductRepository extends ServiceEntityRepository
{
public function createFindQueryBuilder(string $alias = 'o'): ?QueryBuilder
{
return parent::createFindQueryBuilder($alias);
}
}
You might wonder: “Why add the nullable ??” The parent method in Symfony 6 returns null in some edge cases (like when the entity has no mapped fields), so our override must reflect that. PHP will throw a ReturnTypeMismatch error if our return type doesn’t match the parent’s.
Security Component Changes
The security system saw major changes in Symfony 6. The Guard component—which provided a simplified way to create custom authenticators—has been removed entirely. If you’re using Guard, you’ll need to migrate to the new authenticator system. This migration can be non-trivial depending on the complexity of your authentication logic; the official Symfony documentation provides a migration guide with concrete examples.
Additionally, the UserInterface interface changed. The getUsername() method is now deprecated (removed in Symfony 6) and replaced by getUserIdentifier(). Here’s the before and after:
// Before (Symfony 5)
use Symfony\Component\Security\Core\User\UserInterface;
class User implements UserInterface
{
private string $email;
public function getUsername(): string
{
return $this->email;
}
// ... other methods
}
// After (Symfony 6)
use Symfony\Component\Security\Core\User\UserInterface;
class User implements UserInterface
{
private string $email;
public function getUserIdentifier(): string
{
return $this->email;
}
// ... other methods
}
What if you need to support both Symfony 5 and 6 simultaneously during a gradual rollout? You can implement both methods temporarily—though this approach should be considered transitional:
public function getUsername(): string
{
return $this->getUserIdentifier();
}
public function getUserIdentifier(): string
{
return $this->email;
}
For a clean migration, remove getUsername() once you’re fully on Symfony 6. Of course, if you have many user classes or third-party user bundles, this change can involve more coordination.
Controller Convenience Methods Removed
If you extend AbstractController, you might be used to calling $this->get() or $this->getDoctrine() to access services. These shortcut methods are gone in Symfony 6. The recommendation? Use constructor injection instead.
Before (no longer works):
class ProductController extends AbstractController
{
public function index(): Response
{
$em = $this->getDoctrine()->getManager();
$products = $em->getRepository(Product::class)->findAll();
return $this->render('product/index.html.twig', [
'products' => $products
]);
}
}
After (recommended):
use Doctrine\ORM\EntityManagerInterface;
class ProductController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em
) {}
public function index(): Response
{
$products = $this->em->getRepository(Product::class)->findAll();
return $this->render('product/index.html.twig', [
'products' => $products
]);
}
}
The benefits of constructor injection are well-known: explicit dependencies make your code more testable and clearer. If you have many controllers, this change can feel tedious. Though you can write automated refactoring scripts to help, manual review ensures you understand what each dependency actually does.
HttpFoundation Changes
RequestStack::getMasterRequest() has been renamed to getMainRequest(). The old method still exists but is deprecated. If you call this method in your code, update it:
// Before
$request = $requestStack->getMasterRequest();
// After
$request = $requestStack->getMainRequest();
The reasoning behind the rename? The term “main request” better expresses its purpose: it returns the primary request in the stack, as opposed to sub-requests. This is clearer than “master,” which carried historical baggage.
Removed Components
Symfony 6 removes several components that were previously deprecated:
- Inflector (use
String\Component\Inflector\EnglishInflectorinstead, or a dedicated library likedoctrine/inflector) - Form extensions (some moved to separate packages)
- VarDumper component’s
dump()function in production (still available in dev via the VarDumper component)
If you were using the Inflector, here’s how to migrate:
// Before
use Symfony\Component\String\Inflector\EnglishInflector;
$inflector = new EnglishInflector();
$plural = $inflector->pluralize('cactus'); // ['cactuses', 'cacti']
// After - install doctrine/inflector
composer require doctrine/inflector
use Doctrine\Inflector\InflectorFactory;
$inflector = InflectorFactory::create()->build();
$plural = $inflector->pluralize('cactus'); // 'cactuses'
API Changes You Might Miss
Some changes are subtle. For instance, the DateTimeNormalizer now expects ISO 8601 format by default, not whatever PHP’s DateTime constructor accepts. If you’re serializing/deserializing dates in APIs, verify your formats:
// Before: "2026-03-16 15:30:00" worked
// After: "2026-03-16T15:30:00+00:00" recommended (ISO 8601)
Also, event subscribers now require explicit priority ordering. If you relied on implicit ordering before, you might need to add priority attributes:
// Before (priority based on registration order)
class KernelEventsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 0],
];
}
}
// After (still works, but explicit is clearer)
class KernelEventsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [
KernelEvents::REQUEST => [
['onKernelRequest', 0],
['onKernelRequestOther', -10],
],
];
}
}
These are just the most common changes. You’ll want to scan the full UPGRADE-6.0.md file for issues specific to your application’s Symfony usage. Look for the sections matching the components you use most heavily.
Phase 4: Testing and Final Touches
With the code and configuration updated, the final phase is to ensure everything works as expected.
1. Clear the Cache
After a major version upgrade, it’s a good practice to clear the cache completely. Symfony’s cache contains compiled containers and metadata that can persist across upgrades, potentially causing subtle issues. You can clear all environments with:
$ rm -rf var/cache/*
Of course, if you’re using a shared hosting environment or have custom cache configurations, you may need to adjust this command. In production environments, you’d typically use php bin/console cache:clear --env=prod instead, but for the upgrade process itself, removing the entire cache directory is the most thorough approach.
2. Run Your Test Suite
Execute your test suite to make sure all parts of your application are functioning correctly. This is where a comprehensive test suite becomes invaluable.
./bin/phpunit
Address any test failures. These will often point you to areas of your code that need to be updated for Symfony 6.
Conclusion
Migrating from Symfony 5 to Symfony 6 is a manageable process when approached systematically. By preparing your application, fixing deprecations, and carefully addressing backward-compatibility breaks, you can ensure a successful upgrade.
That said, it’s worth acknowledging that the migration requires a time investment—typically a few days for small applications, and potentially weeks for larger, more complex ones. The exact timeline depends on factors like test coverage, number of custom bundles, and complexity of your security setup. Though the process is straightforward in principle, each application presents its own challenges.
The result, though, is a more modern, performant, and secure application that leverages PHP 8.1+ features and will be supported with security updates for years to come. With the roadmap we’ve outlined, you’re well-equipped to make the transition with confidence. If you encounter issues not covered here, the official Symfony documentation and community resources are excellent next steps.
Sponsored by Durable Programming
Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.
Hire Durable Programming