Every year a handful of teams call me with the same request. They are on Symfony 5.4 or 6.2. They know they cannot stay there. The last person who tried to upgrade left the company, burned out, halfway through. The board has started asking when the upgrade will ship, and the answer everyone keeps giving is “next quarter.” It has been next quarter for three quarters.
This is not a Symfony problem. It is a deprecation debt problem that Symfony makes visible, on a schedule, every two years. And the good news, if you can stomach hearing it, is that the upgrade itself is almost never the hard part. The upgrade is a mechanical operation that Rector can do in an afternoon. What is hard is all the work you skipped for three years because you were shipping features, and the upgrade is now asking you to do it all at once.
This essay is about how to stop treating Symfony major upgrades as projects and start treating them as a byproduct of continuous hygiene. If you get the hygiene right, the upgrades stop being events. If you do not, no playbook in the world will save the next one from being a death march.
Why Symfony upgrades get delayed
The pattern is the same in every engagement. It looks like this:
- A major version ships. Your team reads the release notes, skims the deprecations, and agrees to “look at it next sprint.”
- Next sprint becomes next quarter. Deprecation warnings accumulate in the logs and then get filtered out of the dashboard because they drown out real errors.
- Two or three years pass. The current LTS is now two majors ahead of you. A dependency has dropped support for your version. A security advisory affects a bundle you cannot upgrade because the new version requires the new Symfony.
- Engineering proposes the upgrade as a standalone project. The scope estimate is six months. The board asks why the estimate is six months. Engineering cannot explain in a way the board finds convincing.
- The upgrade does not get funded. The team routes around the problem for another year. The scope grows to nine months.
I have seen this loop four or five times now. The team is not incompetent. The scope estimate is probably honest. The loop happens because at every decision point, the cheapest-looking action is to postpone, and the cost of postponing is invisible until it suddenly is not.
The first thing I do in an engagement like this is make the cost visible. Not in person-months (the board does not care about person-months). In terms of what is blocked: “the payment provider SDK bumped its minimum Symfony version and we cannot take the security patch, so we are running an unpatched webhook handler against a library advisory from eight months ago.” That is a sentence a board can act on.
The upgrade is not the work
The second thing I tell teams is that the upgrade itself, narrowly defined, is one to three days of work. Actually bumping symfony/* constraints in composer.json, running composer update, running Rector with the Symfony set, and fixing the mechanical breakage. Rector’s Symfony sets have genuinely caught up to where, for most straightforward apps, the mechanical migration is automated.
The work that actually takes six months is everything surrounding that core operation:
- Deprecation cleanup you deferred. Every
@trigger_erroryou silenced, every “we will fix this before 6.0” TODO, every “compatibility layer” that was supposed to be temporary. All of it comes due on upgrade day. - Bundles that have not kept up. A long tail of small bundles with one maintainer who moved on. Each one becomes a decision: upgrade it, fork it, replace it, remove the feature.
- Custom compiler passes and container extensions written against internal APIs that have since been refactored. Rector does not always know about these.
- Tests that pass only because of deprecation leniency. A test asserting that a service has a certain tag, where the tag format changed. A test that uses a deprecated assertion method. A test that relies on the request flow that no longer works the same way.
- Infrastructure that has to move in lockstep. A PHP minor bump that requires a Docker base image change that requires a CI runner update that requires a new OpenSSL build. Any one of these can become its own week.
The mistake most teams make is estimating the upgrade using only item 1, maybe item 4, and assuming the rest is noise. The rest is not noise. The rest is the upgrade.
The continuous upgrade discipline
Once you see that the work is deprecation cleanup and bundle hygiene, the question stops being “how do we do the upgrade?” and becomes “how do we do the cleanup continuously so the upgrade is a non-event?” The answer is a small set of practices that, if you adopt them honestly, make the next five years of upgrades cost one week each instead of one quarter each.
1. Treat every deprecation as a real error
Symfony gives you a rich deprecation layer. You can turn it into noise, or you can turn it into a lint rule. Use the second option.
In every phpunit.dist.xml I set up, I configure the deprecation handler to fail the build on any deprecation triggered by your own code. Vendor deprecations get a separate budget, because you cannot always fix them immediately, but they are logged and counted.
<phpunit>
<php>
<env name="SYMFONY_DEPRECATIONS_HELPER"
value="max[self]=0&max[direct]=0&quiet[]=indirect&quiet[]=other"/>
</php>
</phpunit>
The semantics: zero self-triggered deprecations, zero direct deprecations from vendors you call (these are your problem), silence indirect deprecations (those are the library author’s problem). This setting turns your test suite into the deprecation dashboard. If CI is green, you have no deprecation debt in first-party code. If CI is red, you know precisely what to fix.
The psychological effect matters more than the technical one. Once a deprecation breaks the build, engineers fix it in the PR that introduced it, instead of filing a ticket nobody will ever pick up. The cleanup becomes continuous and small, not deferred and enormous.
2. Keep Rector on every PR
Rector is the second leg of the stool. Run it in CI on every pull request, configured to apply the Symfony sets for your current version plus the one ahead. This gives you two benefits: your code style stays current, and you get advance warning when a Rector rule would introduce a breaking change, because the PR diff shows it before you merge.
A minimal rector.php for a Symfony app on the current minor:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Symfony\Set\SymfonySetList;
use Rector\ValueObject\PhpVersion;
return RectorConfig::configure()
->withPaths([__DIR__.'/src', __DIR__.'/tests'])
->withPhpVersion(PhpVersion::PHP_83)
->withSets([
SymfonySetList::SYMFONY_74,
SymfonySetList::SYMFONY_CODE_QUALITY,
SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION,
])
->withImportNames(importShortClasses: false);
When Symfony 8.0 ships, you add the 8.0 set to a branch, run it, and see what breaks. That branch does not get merged yet, but the diff is now concrete. You know, months before the release, what the actual migration surface for your app looks like.
3. Budget bundle churn into every sprint
The third leg is the least technical and the most often skipped. Every sprint, allocate a small slice (I usually suggest 10 percent) for bundle updates. Not emergency security patches. Routine bumps. Review the Composer audit output. Upgrade the three bundles with the smallest diff. Read the CHANGELOG of each one.
This is boring work. Junior engineers will complain about it. It is also the work that prevents the “bundle X stopped supporting our Symfony version” surprise. If you were already on the latest minor of bundle X, the next major of bundle X is a diff you review, not a surprise you panic about.
I have never seen a team that did this consistently have a hard Symfony upgrade. I have seen many teams that did not do this and spent three months catching up on bundle versions as part of an upgrade project.
The month-by-month plan
If you are reading this and you are already three majors behind, the continuous practices above are the destination, not the starting point. Here is the plan I run with teams in that position.
Month 1: Audit and deprecation freeze
Week 1 is spent measuring. Not fixing. Just measuring. You are producing three artifacts:
- A deprecation report. Run the current test suite with the deprecation helper in loud mode. Categorize every deprecation by source (own code, direct vendor, indirect), by Symfony version that introduced it, and by rough count. This is your baseline.
- A bundle inventory. For every bundle in
composer.json, record the installed version, the latest version, the maintenance status of the repo, and whether the latest version supports the Symfony version you want to reach. Color-code the ones that do not. - A Rector dry-run. Run Rector with the target Symfony version set,
--dry-run, and inspect the output. You are looking for the volume and shape of automated changes. This sets expectations.
Week 2 begins the freeze. From this week on, no new deprecation may be introduced. CI enforces this via the test suite configuration from earlier. This does not fix the existing deprecations. It just stops the hole getting deeper while you patch it.
Weeks 3 and 4 start the deprecation cleanup at the top of the list. Pick the deprecation class with the highest count and work through it. Fix the usages. Submit small, reviewable PRs. Tag them with a label like deprecation-cleanup so the board sees the velocity.
Month 2: Bundle tree normalization
With deprecations draining, turn to the bundle inventory. Walk top-down from the heaviest dependencies (typically api-platform/core, doctrine/doctrine-bundle, sonata-project/* for some shops) to the leaves. For each bundle:
- Bump to the latest version compatible with your current Symfony version.
- Read the CHANGELOG. Fix any breakage the bump introduces.
- Ship it to production.
- Move to the next bundle.
The rule of thumb: one bundle at a time, each in its own PR, each separately deployable. This sounds slow. It is slow. But if a bundle bump breaks something in production, the revert is surgical. If you bumped ten bundles in one PR, the revert is catastrophic and the fix is a week of forensics.
By the end of month two, your bundle tree is current-within-your-Symfony-version. Your deprecation count is down by at least half. You have not done the upgrade yet. You are preparing the ground.
Month 3: The upgrade itself
Now you bump Symfony. In a single branch:
- Update
symfony/*constraints incomposer.jsonto the target version. - Update the minimum PHP version if required.
- Run
composer updateand resolve the dependency tree. - Run Rector with the target Symfony version sets.
- Run the full test suite. Fix what broke.
- Run the application locally and walk through the main happy paths manually.
- Deploy to staging. Run any load tests you have.
- Deploy to production, off-hours, with the deploy window announced.
This step, if the previous two months were done properly, takes one to two weeks. If the previous two months were skipped, this step takes two to three months and you will regret it.
Month 4: Cleanup and the first continuous cycle
After the upgrade, a small round of cleanup: the @deprecated items that were also-accepted-as-noise in the old version, Rector rules specific to the new version, code style alignment. Two or three weeks, a handful of PRs.
Then the continuous practices from the previous section become routine. The next major, two years out, is now a two-week exercise. The death march is over.
The traps that eat project timelines
A few specific traps I keep seeing in the wild:
Upgrading PHP and Symfony in the same PR. Don’t. A PHP minor bump has its own failure modes (typed properties, readonly semantics, new string/int edge cases), and entangling them with Symfony changes doubles your debug surface. Upgrade PHP first, ship that, bed it in for a week, then upgrade Symfony.
Ignoring FrameworkBundle config drift. Between majors, the shape of framework.yaml changes. The old shape is often deprecated, then removed. Running a fresh symfony new and diffing the generated config against yours catches a lot of quiet deprecation that the test suite alone will not surface. I do this pass every major.
Trusting the container to be the same after upgrade. It will not be. Autoconfiguration changes, tagging defaults change, service visibility changes. Dump the container before and after (symfony console debug:container > before.txt, same after) and diff. The diff will contain surprises. Resolve them before shipping, not in production.
Forgetting about asset pipelines. Symfony’s asset story has changed across recent majors (Webpack Encore, AssetMapper, Stimulus shifts). If your front end was on Encore and you are moving to AssetMapper, that is a separate project with its own timeline. Do not slip it into the Symfony upgrade. Plan it as a sibling.
Treating the deprecation helper as optional. It is the single most important tool in this process. A test suite that does not fail on deprecations is a lint rule you are not running.
When the plan does not apply
Two exceptions, both rare.
If you are more than three majors behind (running 4.4, say, and trying to reach 7.x), the “one upgrade at a time” rule still applies, but the plan runs back-to-back for each major. You do not skip. You walk 4.4 to 5.4, ship, stabilize for two weeks, walk 5.4 to 6.4, ship, stabilize, walk 6.4 to 7.x. Each leg is the month-three work from the plan above. Total elapsed time is six to nine months. That is genuinely a project; the rest of this essay does not pretend it away.
If the application is scheduled for sunset within twelve months, skip the upgrade entirely. Maintain the current version on LTS, apply security backports, and put engineering effort into the replacement. Upgrading a system that is about to be deleted is a resource allocation error, regardless of how satisfying it would be to finish.
The cost of staying
The reason to do any of this, despite how boring the work is, is not aesthetic. It is that staying on an old Symfony version has a compounding cost that is usually underpriced in planning conversations. Three specific costs are worth naming to the business, in this order:
- Security exposure. Every week you stay on an unsupported or soon-unsupported version is a week you are accepting risk that a CVE lands in a component you cannot patch except by upgrading.
- Hiring cost. Candidates read your stack list. Symfony 5.4 in 2026 signals specific things. The talent you want to hire reads that signal the way you would read it if you were job-hunting.
- Bundle ecosystem access. The newest bundles, the newest patterns, the newest API Platform and Live Components versions only target current Symfony. Every year you stay behind, the distance between you and the ecosystem grows, and the ceiling on what you can do with your own codebase comes down.
None of these are urgent on any given Tuesday. All three compound weekly. The job of the upgrade practice is to keep them from ever becoming urgent.
If you are looking at a Symfony version that is two or three majors behind and trying to plan the path forward, my monolith modernisation engagement starts with exactly this audit. A two-week diagnostic on deprecations and bundle health, a costed plan to reach current, and the continuous practices set up so you never fall behind again.
References
- Symfony Releases & Maintenance : official calendar for minor and major releases, plus LTS support windows.
- Rector : the automated refactoring tool behind the Symfony migration sets.
- Symfony PHPUnit Bridge (SYMFONY_DEPRECATIONS_HELPER) : reference for the test-suite deprecation thresholds used in the “treat every deprecation as a real error” section.
- Webpack Encore : documentation for the legacy front-end bundler mentioned in the asset-pipelines trap.
- AssetMapper : documentation for the modern importmap-based front-end pipeline that replaces Encore for many projects.