All articles
12 September 2026
8 min read

Working through Symfony deprecations without drowning in the log

By Tim Rutte, Cloud & Software ArchitectTopicLegacy & Modernization

A dot matrix printer with fanfold paper stacking up in layers, a blue marker lying across it.

Symfony has a path for upgrades that is better than most frameworks': whatever disappears in the next major version announces itself in the current one as a deprecation. Once every message is gone, you can change the major version.

That is what the guide says, and it is how it works. The reason Symfony upgrades still stall is somewhere else: the first run on a grown project produces four- or five-figure numbers of messages, most of them from libraries, and nobody knows where to start.

This article describes how to turn that list into an order of work, and what the deprecation layer does not report. The second part matters more, because that is where the effort sits that blows up estimates.

What the deprecation layer does

Symfony's promise is narrow and therefore dependable: within a major version the public interface stays stable. Whatever disappears in the next major version is marked as deprecated beforehand and reports itself at runtime.

From that follows the route that applies to every Symfony upgrade: first to the last minor version of the current major, clear every message there, then change the major version. Going from 4.4 straight to 6.4 means giving up exactly the warnings the route was built for.

You do not collect those messages in the browser but in the test suite, or, if there is none, in a run across the most important pages. What matters is that the run is reproducible, otherwise progress cannot be measured.

# Collect messages without letting them fail the run
SYMFONY_DEPRECATIONS_HELPER='disabled=1' \
  vendor/bin/phpunit --log-junit=/dev/null 2>deprecations.log

wc -l deprecations.log

The number at the end is the starting size. It is always alarming and always misleading, because the same message appears a thousand times.

Making the log readable

The first useful step is not work on the code but an analysis. Twelve thousand lines regularly turn into forty distinct messages, and forty turns into a list that can be planned.

# Collapse identical messages, most frequent first
sed -E 's/"[^"]*"/"X"/g; s/[0-9]+/N/g' deprecations.log \
  | sort | uniq -c | sort -rn | head -40

Replacing strings and numbers is the trick: without it every message counts as its own, because it contains a class name or a line number.

More interesting than frequency is origin. The message nearly always names the calling site, and that decides who does the work:

grep -oE '(src|vendor)/[a-zA-Z0-9_./-]+' deprecations.log \
  | cut -d/ -f1-3 | sort | uniq -c | sort -rn | head -20

In practice eighty per cent of the messages then sit under vendor/, and that is good news: for the bulk of the work, bumping a package version is enough.

Three kinds of message, three answers

Every line in the log falls into one of three categories, and confusing those categories is why effort gets estimated wrongly.

One: your own code. A place under src/ calls something deprecated. That is the kind people picture when they think about an upgrade, and it is usually the smallest part. The guide names the replacement, the change is local.

Two: a package that has to catch up. The call sits under vendor/. You change nothing in your own code here, you raise the package version. The effort is not in fixing but in ordering: which version of the package works with which Symfony version, and which one drags other packages along.

Three: a package that will not catch up. This is the expensive case. The package is abandoned, its last release does not support the new major version, and there is no successor. Every one of those findings is a decision of its own: replace, adopt, or drop the feature.

That third kind should be looked for first, not last. It decides whether the upgrade takes three weeks or three months, and it is not in the message count:

composer outdated --direct --format=json \
  | php -r '$d=json_decode(file_get_contents("php://stdin"),true);
    foreach($d["installed"] as $p) if(!empty($p["abandoned"]))
      echo $p["name"], " -> ", is_string($p["abandoned"]) ? $p["abandoned"] : "no successor", PHP_EOL;'

Whether this jump becomes an appointment at all rather than a permanent topic is decided by a calendar across the whole stack: An end-of-life calendar for the whole stack.

The order: from the bottom up

With the three categories, an order emerges that lowers the effort noticeably, because it avoids doing work twice.

  1. Settle the abandoned packages. Only once their fate is decided is the scope known. A replacement often changes more code than all the deprecations together.
  2. Raise packages, one at a time. Regenerate the log after every bump. The number drops in steps, and each step can be rolled back on its own.
  3. Only then your own code. What is left now is genuinely yours. Anybody starting here fixes messages a package bump would have handled anyway.
  4. Change the major version when the log is empty, and not before.

The second item is where patience pays. Raising every package at once leads to a state where something is broken and nobody knows which of the fourteen bumps did it.

Which packages are installed at all, and which of them were modified by hand, is settled by Composer as archaeology.

What the deprecation layer does not report

And here is the part that makes Symfony upgrades take longer than planned despite a clean log. Three rebuilds never announce themselves, because they do not live in calls but in structure.

The project structure. A project created before Symfony Flex has a different directory layout, keeps configuration in a different place and registers bundles differently. None of that is deprecated in the layer's sense, it is simply not what today's guide assumes. The rebuild is mechanical and still costs days.

The service definitions. Moving from explicitly configured services to autowiring and autoconfiguration is not a deprecation, it is a different way of thinking. You can defer it, and many do. The bill arrives at the upgrade after next, when the guide only describes today's way.

The security layer. Rebuilding the security bundle is where most projects come to a halt. Authenticators, firewalls, access rules: that is not search and replace, it is rewriting a part nobody likes touching, because a mistake there is immediately a security problem.

For estimating, that means: the message count describes the smaller half of the work. Anybody basing a quote on it alone will be wrong predictably.

Rector, and where it stops

For the mechanical parts there are rule sets that rewrite the code automatically. For Symfony they are cut by major version, and they handle a large share of the first category.

// rector.php
return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withSets([
        SymfonySetList::SYMFONY_54,
        SymfonySetList::SYMFONY_60,
        SymfonySetList::SYMFONY_CODE_QUALITY,
    ]);

Two rules about that, both from experience.

One rule set per run, one commit per rule set. Running every set at once produces a diff across eight hundred files that nobody reads. Kept apart, every step stays traceable, and the one that broke something can be found.

The diff gets read, not skimmed. Rector rewrites correctly what it understands. With code that assembles class names at runtime or uses magic methods, it understands less than it thinks it does. That is exactly where the changes happen that only surface in production.

What Rector does not touch is the whole section above: structure, services, security. That stays manual work, and it is the part that determines how long the project runs.

How to scope that tool in a legacy project without ending up at four thousand changed files is covered in Rector in a legacy project.

Why you do not skip a major version

The question comes up in every second project: if we are on 4.4 anyway and 7 is the target, can we not leave out the steps in between?

Technically yes, practically no, and the reason is the layer itself. Every major version only reports the changes to the next one. Taking two steps at once means having no warnings for the second, and finding those places where nobody wants to find them.

On top of that comes an economic argument that weighs more than the technical one: the intermediate steps ship on their own. A project that reaches 5.4 after three weeks and runs in production has locked in value, even if nothing moves for a quarter afterwards. A project that is "almost on 7" after three months has nothing.

The same logic applies to the PHP version underneath: it has its own rhythm and its own breaking changes, and the two jumps do not belong in the same delivery. What the PHP jump really involves is covered in its own article.

If you would rather not walk that path yourself: how a Symfony upgrade works with me is on its own page, including the part where Rector runs first and what is left gets measured rather than guessed.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.