On 26 August I measured what it costs to move a real Symfony codebase from 7.4 to 8.1: a kit with a few hundred tests, PHPStan at level max, Doctrine on PostgreSQL. Four lines of code, one failing test, zero new PHPStan errors, zero new deprecations, and one cache step that turns "the whole app returns 500" into "fine". I published those numbers in a Slack thread where a Symfony core contributor had rightly pushed back on a kit that pins the LTS, and they went into an upgrade guide.
Today I ran the same upgrade again, on the same codebase, four weeks and two patch releases later. Four of those five numbers changed. Two of the changes are Symfony patch releases fixing what I had blamed on the major, and one of those two bugs was in 7.4 too.
The bench
Two throwaway copies of the kit at the same commit (git archive, nothing touched in the real repository), PHP 8.5.7, Composer 2.9.7, a throwaway PostgreSQL 18.3 database. One copy stays on its lock file, Symfony 7.4. The other gets the upgrade:
- every
"7.4.*"incomposer.jsonbecomes"8.1.*"(26 occurrences,extra.symfony.requireincluded); -
require.phpgoes from>=8.4to>=8.4.1andconfig.platform.phpfrom8.4.0to8.4.1; -
composer update --with-all-dependencies; - two method signatures widened;
- the test suite, PHPStan, and the deprecations, on both copies.
The whole run is one shell script, and every number below comes from its output.
Then and now
26 August (8.1.5) 24 September (8.1.7)
Resolution blocked by third-party none none
Lines of application code to change 4, in 2 files 4, in 2 files
Failing tests 1 0 out of 483
Stale cache compiled under 7.4 every page 500 green
New PHPStan errors, level max 0 2, not from Symfony
New deprecations from Symfony 0 2
What did not move first, because it is still the part that looks worst and costs least.
The resolution fails with 22 problems, and none of them is a bundle. Every one reads like a third-party conflict and is the same line: symfony/... v8.1.0 requires php >=8.4.1 -> your php version (8.4.0; overridden via config.platform, actual: 8.5.7). The server runs 8.5; the platform pin in composer.json says 8.4.0, and 8.1 wants 8.4.1. Two lines, and the resolution goes through. It updates 100 packages today, installs 3 and removes 3.
The two breaking changes are fatal errors while the container is built, so nothing can slip through: Voter::voteOnAttribute() gains a ?Vote $vote = null parameter, and UserCheckerInterface::checkPostAuth() receives the token. PHP names the file and the line. That is the four lines.
The failing test was a patch regression, and it was in 7.4 too
In August, one test failed: a controller that streams server-sent events, where the body reached the test client empty. The status was 200, the Content-Type was text/event-stream, and the controller does what every SSE endpoint does after each event:
echo 'data: '.json_encode($payload, \JSON_THROW_ON_ERROR)."\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
The diagnosis at the time was correct as far as it went. HttpKernelBrowser::doRequest() in 8.1.5 captured the body with a plain ob_start() and read it back with ob_get_clean(). ob_flush() on a buffer without a callback pushes the content through to the parent buffer, so by the time ob_get_clean() runs there is nothing left. We wrote it down as "what changed in 8.x".
It was not 8.x. Today the test is green on 8.1.7, so I went looking for the version where it came back, and the answer is in symfony/http-kernel's history:
- 20 August: "Send the response content before terminating the kernel in the test client". That commit introduced the plain
ob_start()indoRequest(), on the 6.4 branch, merged up. - 22 August: released in 7.4.17 and 8.1.5.
- 23 August: "Capture flushed content in HttpKernelBrowser", which switches both capture points to
ob_start()with a callback that accumulates each chunk and returns an empty string. - 30 August: released in 7.4.18 and 8.1.6.
The proof that it is not a major-version change is to leave the 7.4 copy on 7.4 and move one package:
Symfony 7.4, http-kernel 7.4.13 (the kit's lock) ChatTest OK (7 tests)
Symfony 7.4, http-kernel 7.4.17 ChatTest 1 failure, the same test
Symfony 7.4, http-kernel 7.4.18 ChatTest OK (7 tests)
Symfony 8.1, http-kernel 8.1.5 ChatTest 1 failure
Symfony 8.1, http-kernel 8.1.7 ChatTest OK (7 tests)
The mistake in August was a comparison with two variables. The 7.4 reference ran on an http-kernel locked since May; the 8.1 branch had just resolved to a patch release that was four days old. Any project that ran composer update on 7.4 between 22 and 30 August and streams a response in a functional test saw the same failure, with no upgrade at all.
The 500s were a removed class, and 8.1.6 put a stub back
The other finding from August was that after the upgrade, every page returned a 500 on Class "Symfony\Component\VarExporter\Internal\Hydrator" not found until the cache directory was deleted. Symfony 8.1 had removed that internal class, and code exported by symfony/var-exporter under 7.4 (cache pools, in this case) still called it.
Replayed today with the test cache compiled under 7.4 copied into the 8.1 copy, and no cache:clear:
var-exporter 8.1.5 483 tests, 38 errors, 65 failures, 530 lines "Internal\Hydrator not found"
var-exporter 8.1.6 OK (483 tests)
On 29 August, symfony/var-exporter 8.1.6 added the class back as a stub whose only method throws a LogicException: "Code exported by symfony/var-exporter < 8.1 cannot be loaded anymore and must be regenerated." A cache pool treats the exception as a miss and regenerates the entry, where a missing class was a fatal error. Deleting var/cache after changing a major version is still the right habit. It is no longer the difference between a working app and a broken one.
Deprecations: the count depends on whether the container is compiled in that run
In August I wrote that Symfony 8 adds no deprecation to this codebase and removes one. The second half holds: the 7.4 deprecation about User::eraseCredentials() is gone on 8.1. The first half does not. Same suite, ignoreIndirectDeprecations off, run twice in a row, first with var/cache/test deleted, then with the container already compiled:
cold container warm container
Symfony 7.4 (reference) 5 3
Symfony 8.1.7 19 13
Two deprecations come from Symfony 8.1 and exist since 8.1.0, and both only show up in the cold run, because they are raised while the container is compiled:
-
Setting the "framework.profiler.collect_serializer_data" configuration option is deprecated. It will be removed in version 9.0.The kit sets it inweb_profiler.yaml. Relying solely on the name of parameter "$anthropicClient" of "__construct()" to match a named autowiring alias is deprecated; use the "#[Target]" attribute.
A run on a warm container reports neither. I cannot reconstruct the state of the cache during the August run, so I will not claim that is what happened then, but it is the simplest explanation for a zero. If you count deprecations to size an upgrade, delete var/cache/test first.
What --with-all-dependencies brings that is not Symfony
The rest of the new deprecations and both PHPStan errors come from packages that moved because -W let them:
-
twig/twig3.29 deprecates calling a macro without a value for an argument that has no default (10 occurrences, one macro). -
doctrine/ormdeprecates passing a string as the direction toQueryBuilder::orderBy(). -
horstoeko/zugferdwent from 1.0.123 to 1.0.132, and a tighter PHPDoc return type makes PHPStan flag anis_string()and aninstanceofin a test as always true and always false. Pin it back to 1.0.123 on the 8.1 copy and PHPStan is at zero errors again.
None of that is the cost of Symfony 8.1, and all of it lands in the same pull request. When you size the upgrade, count the two separately, or a Twig macro and an e-invoicing library end up in the bill for Symfony 8.
What this does not measure
It measures the kit as it ships, not an application written on top of it: your own code has its own API surface. The SSE test passing again proves the test client captures the stream; it is still not a run in a real browser under 8.1. And one reproduction of the August state is missing: in my replay, cache:clear on the stale cache was enough to clean it, where the guide says it is not, so the only claim I keep is the one I could replay, delete the directory.
The codebase is ShipAnvil, a multi-tenant Symfony kit that stays pinned to 7.4 LTS: here is what the source code licence contains and costs. The upgrade guide it ships was written from the August run, and as of today three of its lines are wrong in the ways described above.
The general lesson is smaller than the numbers: when a major upgrade breaks something, install the same patch release of the failing component on the old branch before you blame the major.











