There are two types of upgrades. One follows only UPGRADE.md
files on every release, replacing what has been removed with new alternatives. It works, and we could say that the codebase will be "up-to-date."
The other upgrade doesn't stop at the required minimum but makes use of all modern features the framework provides. It will be faster, easier to understand, and easier to upgrade to the next version. I wrote a post that explains why the latter is better.
There are no sources about Symfony upgrades spanning multiple major versions—time to fix that.
This was a reply to question on Reddit that grew into a post. Today, we look at the less-spoken steps that bring the real value of modern Symfony. If you do them, a new Symfony developer who joins your team will have a good time onboarding and working with your code.
For the happy path upgrade:
UPGRADE.md
files in the Symfony Github repositoryHere is a complete list of UPGRADE.md
files: 2.8, 3.0,
3.1,
3.2,
3.3,
3.4,
4.0,
4.1,
4.2,
4.3,
4.4,
5.0,
5.1,
5.2,
5.3,
5.4,
6.0,
6.1,
6.2,
6.3,
6.4,
7.0,
7.1,
7.2
If you're using a later version like Symfony 4, 5, or 6, still check previous steps. Some projects have Symfony 7 in their composer.json
, but their syntax and architecture are stuck on Symfony 3 times. Imagine the surprise when they're hiring for a modern Symfony 7 codebase, but then the developers see a code fossil.
Should we upgrade first to PHP 8, then start with Symfony 3 to 4 to 5, or vise versa? This is a legit question and one path might lead you to serious turmoil if PHP bugs (don't always trust composer requirements in the past).
Here is a simple table of minimal PHP versions required by various Symfony versions:
Symfony 4 or 5 was not tested on PHP 8.0. It should work, but I've experienced a few bugs when it crashes on an invalid internal type or return value. Those are unfixable, as we'd have to change PHP itself.
In my experience, it's best to reach the highest Symfony version possible. If our PHP version blocks us from going further, then upgrade the PHP version by a single minor step.
For example, let's say we're upgrading the project from Symfony 4 to 7 and running PHP 7.4. First, we upgrade 4 to 5.0, then to 5.4. Why? Because Symfony 5.4 is the last version** that runs on PHP 7.4. Symfony 6.0 requires us to upgrade PHP to 8.0. Only then do we upgrade PHP 7.4 to 8.0.
The main change in Symfony 3 was the directory structure. In short, everything used to be placed in the /app
and /Resources
directories. Now, everything is directly in the root directory.
Give the /Resources
directory some love, as most of the templates, translations, tests, configs, etc., are nested there. It will make working with the Symfony project easier—not just for you but also for linters, static analyzers, and Rector.
The 2nd important change is moving from a string-named service to and global container...
$someService = $this->get('some_service');
...to a typed constructor injection:
/**
* @var SomeService
*/
private $someService;
public function __construct(SomeService $someService)
{
$this->someService = $someService;
}
This is mainly spread in:
This sole change consumes ~40 % of all upgrade time, but the value increase is a hundredfold. Constructor injection is one of the best patterns for creating clean and adaptable architecture.
There is a special Rector set to help with the upgrade. Use one rule at a time, refactor your code, send a pull request, merge, and then add the next rule.
In short, the change is:
services.yml
Symfony\Bundle\FrameworkBundle\Controller\AbstractController
classservices.yml
, tooAlso, you can clean your service configs:
# services.yml
services:
+ _defaults:
+ autowire: true
SomeService:
- arguments:
- $someArgument: '@SomeType'
- $someArgument: '@AnotherType'
I wrote a dedicated post about configs upgrade, so you won't miss any line you can remove.
Is your project still using the following dependency in composer.json
?
{
"require": {
"symfony/symfony": "^3.0"
}
}
Flip this to a monorepo split. Instead, require each package separately:
{
"require": {
"symfony/http-kernel": "^3.0",
"symfony/console": "^3.0",
"symfony/finder": "^3.0",
"symfony/dependency-injection": "^3.0",
"symfony/config": "^3.0",
"symfony/yaml": "^3.0",
"symfony/translation": "^3.0",
}
}
This is not just do add us more work and make our composer.json
bigger. It will ease the upgrade:
symfony/symfony
download {
"require": {
"symfony/http-kernel": "^3.0",
- "symfony/console": "^3.0",
+ "symfony/console": "^4.0",
- "symfony/finder": "^3.0",
+ "symfony/finder": "^4.0",
"symfony/dependency-injection": "^3.0",
- "symfony/config": "^3.0",
+ "symfony/config": "^4.0",
- "symfony/yaml": "^3.0",
+ "symfony/yaml": "^4.0",
- "symfony/translation": "^3.0",
+ "symfony/translation": "^4.0",
}
}
Packages that are hard to bump and should go the last:
symfony/framework-bundle
symfony/dependency-injection
symfony/http-kernel
You can upgrade to Symfony 4 while handling named services upgrades, but Symfony 4.4 is the last one to allow it. Symfony 5 would crash.
Symfony 3.3 introduced PSR-4-based service discovery. It was slightly buggy until Symfony 4.0, so I would first upgrade to Symfony 4 before using it.
What is it? Before, we had to register each service manually - one by one:
// services.php
$services = $containerConfigurator->services();
$services->register(App\Repository\ProductRepository::class);
$services->register(App\Repository\CategoryRepository::class);
$services->register(App\Repository\CartRepository::class);
$services->register(App\Repository\OrderRepository::class);
Now we can load them all from 1 directory:
$services = $containerConfigurator->services();
$services->load('App\\Repository\\', __DIR__ . '/../src/Repository')
If we add a new *Repository
class, Symfony will automatically pick it up.
You've noticed we're not using the YAML syntax anymore. Why? Symfony 3.4 has added a PHP fluent syntax for configs. Again, we better wait for Symfony 4 to make it reliable.
First, let me give you 10 reasons why PHP beats YAML.
"But we have over 50 configs", you say.
No worries, there is a migration tool that automates 99 % of the process.
Last, but not least, I wrote a dedicated post about the benefits of modern PHP Symfony configs, how it works perfectly with PHPStan deprecation rules to get warnings about deprecated config methods. This is not possible with YAML.
"Perfection is achieved, not when there is nothing more to add,
but when there is nothing left to take away."
When we finish the config migration, service narrowing and remove every possible piece we don't need, what will the result be?
1 config per environment, that is:
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()
->public()
->autowire()
->autoconfigure()
->bind('$environment', '%kernel.environment%');
$services->load('App\\', __DIR__ . '/../src')
->exclude([
__DIR__ . '/../src/App/Entity',
__DIR__ . '/../src/App/Event',
__DIR__ . '/../src/App/ValueObject',
]);
Symfony 5.2 added [# [Route] and # [Required] attributes] (https://symfony.com/blog/new-in-symfony-5-2-php-8-attributes). As I've said above, we should first upgrade to Symfony 5.4 while still on PHP 7.4 and then to PHP 8.0.
You can prepare early within the Rector config:
# rector.php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withAttributesSets()
The ->withAttributesSets()
method enables all relevant attribute sets in your project. But don't worry; it will only start upgrading once we're on PHP 8.0. Once we're on PHP 8.0, it will only upgrade those attributes that really exist.
Symfony 3 introduced a new way to handle authentication - Guard. It got further improved and promoted. Then deprecated in Symfony 5.3 to be replaced with new authentication system.
This is the most dynamic component in Symfony, changing with every major version. It's like the opposite of the Form component.
I've had the fortune to work with projects that were not coupled with Symfony security. If that was the case, we slowly decoupled authentication logic from Symfony security to our own. It made and will make our migration much easier.
Security upgrade depends on each specific project, but it's worth skipping the Security Guard completely. It's a dead end.
When we reach PHP 8.0 and Symfony 6.0, 95 % of the work is already behind us. Symfony 6 and 7 are stabilizing and relaxing releases. They're mostly about syntax sugar and more attributes. I would not recommend using them all blindly just because it's PHP 8.0 syntax, though.
But there are a few worth mentioning that bring a value:
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
class HandlerCollection
{
/** @var HandlerInterface[] */
private $handlers;
public function __construct(
#[TaggedIterator(HandlerInterface::class)]
private iterable $handlers
) {
}
}
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class MyService
{
public function __construct(
#[Autowire(env: 'PROJECT_ENVIRONMENT')]
private $environment,
) {}
}
They both allow to drop even more config blurbs and make them more leaner.
This would be the off-the-beaten path to upgrading Symfony 2.8 to 7.2. It's a lot of work, but with the right tools and mindset, it can be done in months. The final result is worth it! Good luck and have fun.
Happy coding!
Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!