Migrating Symfony with zero service interruption
Migrating a Symfony application from version 2.8 to 6.4 is a major challenge. At Keytchens, we carried out this migration on a production platform handling real-time orders, without any service interruption.
Four major versions separate Symfony 2.8 from Symfony 6.4. Along the way, the directory structure changed, dependency injection became automatic, the security system was rewritten and PHP went from version 5 to version 8. This article presents a method for taking these steps one at a time, while keeping the application in production at every moment.
The principle: follow Symfony's release model
Symfony ships a minor version every six months, and the last minor of each major (x.4) is an LTS release. The key rule is this: a feature is never removed in a major version without having been deprecated in the previous one. An application running on the last minor of a major with zero deprecations can therefore move to the next major without breaking.
The migration thus splits into steps, each one validated and deployed to production before moving on to the next.
A progressive migration strategy
Rather than a full rewrite, we chose an incremental migration:
- Phase 1: Symfony 2.8 → 3.4 (backward compatibility)
- Phase 2: Symfony 3.4 → 4.4 (move to the Flex structure)
- Phase 3: Symfony 4.4 → 5.4 (removal of deprecations)
- Phase 4: Symfony 5.4 → 6.4 (adoption of PHP 8.1+)
Each step also requires a minimum PHP version: PHP 7.1.3 for Symfony 4.4, PHP 7.2.5 for Symfony 5.4 and PHP 8.1 for Symfony 6.4. It is often simpler to upgrade PHP first on the current Symfony version, then migrate Symfony: you only change one variable at a time.
Before you start
- Tests: without functional tests covering the critical user journeys, every step is a gamble. Start there.
- A dependency inventory:
composer outdatedand the list of third-party bundles. An abandoned bundle that is not compatible with the target version must be replaced before the migration, not during it. - A staging environment fed with an anonymized copy of production data.
- Error tracking (centralized logs, a Sentry-type tool) to spot a regression immediately after a deployment.
Must-have tools
Symfony's PHPUnit Bridge lists every deprecation triggered during the tests. Rector applies a large share of the fixes automatically:
# Detect deprecations
composer require --dev symfony/phpunit-bridge
SYMFONY_DEPRECATIONS_HELPER='max[total]=0' php bin/phpunit
# Fix automatically
composer require --dev rector/rector
vendor/bin/rector process --dry-run
vendor/bin/rector process
With max[total]=0, a single deprecation makes the test suite fail: that is the setting to aim for in continuous integration before changing majors. Rector is configured in a rector.php file at the project root, by choosing the rule set that matches the target version:
<?php
use Rector\Config\RectorConfig;
use Rector\Symfony\Set\SymfonySetList;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withSets([SymfonySetList::SYMFONY_54]);
Always run Rector with --dry-run first and review the diff: it is a powerful tool, not an infallible one. On the service container side, php bin/console debug:container --deprecations (available since Symfony 5.1) lists the deprecations raised during compilation.
Once on the Flex structure, the Symfony version is driven from composer.json and then updated with a single command:
{
"extra": {
"symfony": {
"require": "6.4.*"
}
}
}
composer update "symfony/*" --with-all-dependencies
Critical points
The most visible change is service configuration. Symfony 2.8 required declaring every service by hand; since Symfony 3.3, autowiring and autoconfiguration automatically register the classes in src/:
# Service migration (before - services.yml)
services:
app.manager.order:
class: App\Manager\OrderManager
arguments: ['@doctrine.orm.entity_manager']
# After - services.yaml with autowiring
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
If some code still fetches the service by its old identifier, for example $container->get('app.manager.order'), declare a temporary alias to the class, then gradually replace those calls with constructor injection. Services have been private by default since Symfony 4.0: direct container access has to go.
Other points that need attention:
- Directory structure (move to 4.x):
app/configbecomesconfig/,web/becomespublic/, templates move totemplates/and bundles are registered inconfig/bundles.php. - Security: the Guard authentication system was replaced by the new authenticator system, introduced in 5.1 and the only one available in 6.0. Custom authenticators have to be rewritten.
- Commands and controllers:
ContainerAwareCommandwas removed in 5.0, and controllers must receive their dependencies through injection rather than via$this->get(). - Return types: Symfony 6 adds native return types to its interfaces. Classes that implement or extend them (voters, normalizers, commands) must declare the same types.
Handling zero downtime
To keep the service running during the migration:
- Blue-green deployment with Docker
- Feature flags to progressively enable new features
- Automated regression tests covering 85% of the code
- Automatic rollback when errors are detected
Blue-green deployment means running two environments side by side: the current version receives traffic while the new one starts up and passes its health checks. The reverse proxy then switches traffic over, and the old version stays available for an immediate rollback. One essential condition: both versions must work with the same database schema. Doctrine migrations must therefore remain backward compatible, following the approach detailed in the article Zero-downtime database migrations.
Feature flags complete the setup: the new code is deployed but disabled, then enabled for a share of users, and switched off in an instant if a problem appears.
Pitfalls to avoid
- Skipping steps: jumping straight from 2.8 to 4.4 piles up hundreds of changes and makes every error hard to pin down.
- Mixing migration and new features: a migration branch that lives for months becomes impossible to merge. Ship each step quickly, in small pull requests.
- Ignoring third-party bundle deprecations: they will block the next major just as much as your own.
- Forgetting the cache and OPcache on deployment: a
cache:clearand a PHP-FPM reload are essential after each step.
Per-step checklist
- Upgrade PHP to the required version and check the tests.
- Reach zero deprecations on the current version.
- Update Symfony and the bundles to the next major.
- Run Rector, review the diff, fix the remaining cases.
- Validate in staging, then deploy blue-green.
- Monitor errors and performance before tackling the next step.
This migration improved performance by 40% and significantly reduced technical debt.