Symfony 8 is out. If you are running 7.x, the official upgrade guide tells you to fix your deprecations, run Rector, and bump the constraint. All of that is true. None of it is the part that surprises teams two weeks into the branch.
In Symfony 7.x upgrade work, the pattern is often the same: the deprecations are easy, the dependency tree behaves, the test suite mostly stays green, and then the team gets blindsided by something the release notes mention in a single line near the bottom of a page nobody read. This post collects those moments. It is the playbook we hand teams in the audit phase of our Symfony upgrade engagement, and it is what the docs do not bother to tell you because, charitably, they assume you already know.
If you are still on 6.x, this is not the post for you yet. Read our Symfony major version upgrades playbook first, walk 6.x to 7.x as its own project, ship it, stabilise for a week, then come back. Trying to skip a major in one PR is the most reliable way to turn an upgrade into a quarter-long death march.
Order of operations, again, because nobody listens the first time
PHP first. Symfony second. Never both in the same PR.
Symfony 8 requires PHP 8.4. If your production runtime is on 8.2 or 8.3 today, you have at least one PHP minor bump sitting in front of you. The temptation is to bundle it into the Symfony branch because the changelog says it has to happen anyway. Do not do that.
PHP 8.3 introduced typed class constants, the #[\Override] attribute, and some subtle string-to-int coercion changes. PHP 8.4 introduced property hooks and an aggressive deprecation of implicit nullable parameter types. Each of those has its own failure modes, and entangling them with a Symfony major doubles the surface you have to debug when the staging deploy goes red. We always do this:
- Bump PHP to the target minor in its own PR. Run the full test suite. Run the application locally. Ship to staging. Wait a week. Ship to production.
- Once PHP has settled, branch the Symfony upgrade.
The week of waiting is not optional. It is when you find the subtle stuff: a vendor library that produces slightly different output under PHP 8.4, a regex that now matches one extra character, a date format string that emits a deprecation in batch jobs that only run on Sundays.
The container will not be the same after the upgrade
Symfony’s container changes between every major. Autoconfiguration defaults shift. Service tagging conventions change. Compiler-pass ordering gets adjusted. The release notes mention this in a sentence and move on. The consequence on a real app is that a service you depend on may exist before the upgrade and not exist after, or exist with a different visibility, or have a different set of tags applied to it.
We always do this before merging the upgrade branch:
git checkout main
symfony console debug:container --show-private > before.txt
git checkout symfony-8-upgrade
symfony console debug:container --show-private > after.txt
diff before.txt after.txt > container-diff.txt
Then we read container-diff.txt. Every removed service is a place where some piece of code is going to fail at runtime, often weeks after the upgrade ships, when an underused code path fires. Every changed tag is a candidate for a silent behaviour change. Every newly-public service might be exposing something you intended to keep internal.
This step takes thirty minutes. It catches things the test suite cannot catch because tests exercise services through public APIs, not through the container.
framework.yaml drift is the silent killer
Open config/packages/framework.yaml from a fresh symfony new against Symfony 8.0. Now open yours. They are not the same shape. They have not been the same shape since approximately Symfony 5.
The drift is harmless ninety-five percent of the time because the deprecated keys are still accepted with a warning. The other five percent is where you lose a Saturday. A key gets removed in a minor that you applied months ago, and your config has been silently falling back to a default that is not the one you intended. The behaviour is correct on staging because staging mirrors production. It is incorrect against the new major because the default changed.
The fix is unglamorous. Generate a fresh Symfony 8 project. Diff its framework.yaml, security.yaml, messenger.yaml, mailer.yaml, and routes.yaml against yours. For each difference, decide: do we want the new shape, or did we have a reason for the old one? Document the answer in a comment.
We do this pass on every major. It catches one or two real bugs every time.
Deprecation helper config is the thing nobody tunes correctly
Most teams turn the deprecation helper on once, leave it at defaults, and consider the box ticked. The defaults are wrong for most production codebases.
The correct setting for a 7.x application planning a near-term 8.0 upgrade is:
<phpunit>
<php>
<env name="SYMFONY_DEPRECATIONS_HELPER"
value="max[self]=0&max[direct]=0&quiet[]=indirect&quiet[]=other"/>
</php>
</phpunit>
max[self]=0 says no deprecation triggered by your own code is acceptable. max[direct]=0 says no deprecation triggered by a vendor library you directly depend on is acceptable. quiet[]=indirect and quiet[]=other say that deprecations triggered by transitive dependencies are logged but do not fail the build, because you cannot always fix them in the same window.
Wiring this in turns the test suite into the deprecation dashboard. If CI is green, you have zero deprecation debt in first-party code. If CI is red, you have a precise list to work through. The psychological effect of “PRs cannot merge with a deprecation in them” is larger than the technical effect: engineers fix the deprecation in the PR that introduced it, not in a ticket nobody picks up.
The PHPUnit Bridge documentation explains the full vocabulary if you need to tune further.
Rector handles the boring parts, but read its output
Rector with the Symfony 8 set is the closest thing to magic in this whole process. It rewrites attribute imports, replaces deprecated constructor patterns, fixes type-hint shifts, and updates calls to renamed methods. On a healthy 7.x codebase it does ninety percent of the mechanical work in an afternoon.
The trap is treating it as automatic. Rector’s rules are pattern-matching tools. They handle the common shape of a problem, not every variant. We have seen Rector miss:
- Custom Doctrine types that extended a base class whose signature changed
- Voter implementations that overrode a protected method renamed between majors
- Compiler passes that referenced container parameters by interpolating strings instead of using the array accessor
- Custom Twig extensions that wrapped Symfony components whose internal API moved
None of these are bugs in Rector. They are places where the codebase did something the rule was not written to recognise. Always run Rector with --dry-run first, scan the diff, and accept the changes consciously. The minute you start blind-merging Rector PRs, you will eventually merge one that breaks something subtle.
A minimal rector.php for the 7 to 8 transition:
<?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', __DIR__.'/config'])
->withPhpVersion(PhpVersion::PHP_84)
->withSets([
SymfonySetList::SYMFONY_80,
SymfonySetList::SYMFONY_CODE_QUALITY,
SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION,
])
->withImportNames(importShortClasses: false);
Note the config/ directory in withPaths. Plenty of teams forget that Symfony configuration is PHP these days and skip the directory. Rector has rules that update config too.
AssetMapper, Encore, and the asset pipeline trap
If you are on Webpack Encore, the Symfony 8 upgrade does not force you to move to AssetMapper. The release notes are diplomatic about it. The community has, in our experience, moved on.
This is a place where teams underestimate the work. Migrating from Encore to AssetMapper looks like a one-day rip-and-replace because the AssetMapper docs make it look simple. It is rarely a one-day job in a real app. You hit:
- Encore plugins (PostCSS, image optimisation, custom loaders) that have no AssetMapper equivalent
- Stimulus controllers wired through
bootstrap.jspatterns that AssetMapper handles differently - Third-party JS that ships only as a CommonJS module and has not been republished as ESM
- CSP headers that allow specific Webpack-hashed filenames
Plan AssetMapper as a sibling project, not as part of the Symfony 8 PR. If you ship them together and the asset pipeline misbehaves on the production CDN, the rollback strategy is “revert the Symfony upgrade”, and that revert is now load-bearing for two things instead of one.
Doctrine ORM has its own schedule
Doctrine ORM has its own release rhythm, and Symfony 8 does not require a Doctrine ORM major bump by itself. At the time of writing, ORM 3.6 is the current stable line and already allows Symfony 8 components. ORM 4 is something to track, not something to fold blindly into the Symfony branch.
The practical risk is still real. Doctrine DBAL, Doctrine Bundle, migrations, custom types, identifier handling, and entity metadata can all move on timelines that do not match Symfony’s. None of that is hard to handle if you do it as its own step. It is hard if you do it on the same branch as the Symfony major, because now any Doctrine-related failure could be either, and you have to triage which one before you can fix.
Our order on real engagements:
- PHP minor bump, ship, stabilise.
- Doctrine dependency cleanup, ship, stabilise.
- Symfony major bump, ship, stabilise.
Each leg is one to two weeks. The total elapsed time is six to eight weeks. Compressing the three into one branch is twelve weeks of forensic debugging.
Bundle compatibility, in 2026
The community has caught up on Symfony 8 faster than usual. The bundles that mattered to your 7.x app probably already have an 8.x release. The ones we audit most often, in rough order of importance:
api-platform/core: v4 series supports Symfony 8 from the start. v3 does not.doctrine/doctrine-bundle: latest supports Symfony 8 in the same major.sonata-project/*: the Sonata team typically lags by one minor. Check the specific bundle.sentry/sentry-symfony: keep current, the integration evolves quickly.friendsofsymfony/*: mixed. Several of these have maintainers who moved on. Each one is a “upgrade, fork, replace, or remove” decision.liip/imagine-bundle: supports Symfony 8 in the latest minor, but check your image processor backend.
Build a bundle inventory. For each entry, record the installed version, the latest stable, whether the latest supports Symfony 8, and the maintenance status of the repository. Color-code the ones where the answer is “no” or “the repo has not had a commit in eighteen months.” Those are the ones that turn the upgrade from a week into a quarter.
The cutover itself
Once everything above is done, the actual cutover is mechanical. We always do it the same way:
- Bump
symfony/*constraints incomposer.jsonto8.0.*(or^8.0depending on your policy). - Run
composer update. Read every line of the output. Resolve conflicts. - Run Rector with the Symfony 8 set. Review the diff. Accept consciously.
- Run the full test suite. Fix everything red.
- Generate the container diff against the previous branch. Walk the diff.
- Generate the
framework.yamldiff against a fresh project. Walk that diff. - Run the app locally. Click through the main user journeys manually. Things the test suite did not exercise will fail here.
- Deploy to staging. Run any load tests you have. Walk the journeys again.
- Deploy to production in an announced off-hours window. Have the previous deployment ready to roll back if anything is off.
- Watch logs, error rates, and p95 latency for forty-eight hours. The subtle stuff shows up on day two.
If the previous sections were done properly, this is a one-week step. If they were skipped, this is the multi-month death march that gives Symfony upgrades their bad reputation. The death march is not the framework’s fault. It is what happens when an upgrade is treated as a single event instead of the visible top of an iceberg of continuous hygiene.
When to engage outside help
We say this with the bias of running a Symfony consultancy, so weigh it accordingly: there are two situations where bringing in outside help is rational, and one where it is wasteful.
Rational case one: you are more than one major behind and the team has tried twice and failed. The pattern of the failure is informative, and a structured audit will surface the structural reason the upgrade keeps slipping. Usually the answer is bundle hygiene that nobody owns, plus deprecation debt that was never made visible.
Rational case two: you are on time but you have a deadline (vendor end-of-life, a payment integration that requires a newer Symfony, a security advisory) and you cannot absorb the upgrade and the rest of the roadmap at the same time. A focused team in for six weeks gives you back the calendar.
Wasteful case: you want someone else to do the upgrade so your team does not have to learn the practices. This works once. The next major, two years out, you are in the same position, and the next consultancy charges you again. Use the engagement to install the continuous practices, or you are buying a moving service for a problem that wanted a moving company.
References
- Symfony upgrade documentation: the official guide. Read it; this post complements it, it does not replace it.
- Symfony PHPUnit Bridge: SYMFONY_DEPRECATIONS_HELPER: the deprecation-as-error configuration reference.
- Rector: the automated refactoring tool that handles the mechanical part of the migration.
- AssetMapper documentation: the modern asset pipeline, if you are considering the move off Encore in the same window.
- PHP 8.3 release notes and PHP 8.4 release notes: the minor PHP changes you absolutely should not bundle with the Symfony upgrade.