DEV Community

Cover image for Symfony 8.0 Got 8 Months of Support. Your Upgrade Bill Depends on Where Symfony Lives
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Symfony 8.0 Got 8 Months of Support. Your Upgrade Bill Depends on Where Symfony Lives


Symfony 8.0.0 came out on November 27, 2025. On July 29, 2026, the project shipped 8.0.16, its last release, and the branch closed at the end of the month: no more bug fixes, no more security fixes. That is the whole life of a major version, eight months, according to Symfony's own end-of-maintenance post. The 8.0 release page now says "Unmaintained" and tells you to move to 8.1.

If you upgraded to 8.0 on launch day, you got a new major, a PHP 8.4 floor, and a deadline. If you stayed on 7.4, you got the same features and support for bugs until November 2028 and security fixes until November 2029, per the releases page.

Nothing broke here. This is the release process doing exactly what it says it will do. What that process costs your codebase every six months depends mostly on how many of your files know Symfony exists.

The calendar is the contract

Symfony's release process is unusually explicit. A minor version comes out every six months, one in May and one in November. A major comes out every two years, in November of odd years. Each branch gets at most five minors, X.0 to X.4, and the last one is the long-term support version.

The support windows fit in one table:

Version type Bug fixes Security fixes
Standard (X.0 to X.3) 8 months 8 months
LTS (X.4) 3 years 4 years

The same page spells out who each track is for. Fast movers use standard versions: a new one every six months, with "a two months period to upgrade." Companies that want stability use LTS: a new one every two years, with "a year to upgrade."

Apply that to today. Symfony 8.1 shipped in May 2026 and is supported until January 2027. Symfony 8.2 is due in November 2026 with support until July 2027, and 7.4.21 is the current LTS. If you track minors, you sign up for a hop every six months, forever, with eight weeks of overlap each time.

A cream conveyor belt carrying numbered release boxes past a single steady black anchor
Standard releases move every six months. The LTS sits still for years.

What 8.0 actually deleted

Every major ships alongside an LTS. Symfony develops the new major and the last minor of the old branch at the same time. Per the release-process doc, "both versions have the same new features, but they differ in the deprecated features." 7.4 kept the deprecated code. 8.0 removed it.

The end-of-maintenance post puts a number on it: 8.0 removed 13,202 lines of deprecated code and raised the minimum PHP version to 8.4. It also used PHP 8.4 to delete Symfony's own machinery. Native lazy objects, new in PHP 8.4 through ReflectionClass::newLazyGhost() and newLazyProxy(), replaced the LazyGhostTrait and LazyProxyTrait that Symfony used to maintain for lazy services. The UPGRADE-8.0 file says it in one line under VarExporter: "Remove LazyGhostTrait and LazyProxyTrait, use native lazy objects instead."

A few other entries from that file, chosen because they reach into application code:

  • DependencyInjection: "the XML configuration format is no longer supported."
  • Serializer: "Remove class aliases in the Annotation namespace, use attributes instead."
  • Validator: "Change the default value of the $requireTld option of the Url constraint to true."

Read those three again with your codebase in mind. The first one lands in config files. The second one lands wherever a class does use Symfony\Component\Serializer\Annotation\Groups. The third one does not fail at all: a #[Assert\Url] with no explicit argument keeps compiling and starts rejecting http://localhost-style URLs it used to accept.

Where those files live decides your bill.

How a deprecation travels from Symfony to your CI

Every one of those removals was announced first, in code, through one tiny function. symfony/deprecation-contracts defines trigger_deprecation() with this signature:

function trigger_deprecation(
    string $package,
    string $version,
    string $message,
    mixed ...$args
): void
Enter fullscreen mode Exit fullscreen mode

The body is one statement. It prefixes the message with Since <package> <version>:, formats it with vsprintf when you pass extra arguments, and raises it with @trigger_error(..., \E_USER_DEPRECATED).

The @ matters. It keeps the notice out of your production logs. It does not hide it from a custom error handler, because the PHP manual says that with a handler installed, "error_reporting() settings will have no effect and the error handler will be called regardless." That is how the debug toolbar in the dev environment and the PHPUnit bridge can list deprecations that never show up on screen.

You can use the same function in your own packages, which is worth doing when you rename a port method:

public function charge(Money $amount): Receipt
{
    trigger_deprecation(
        'acme/billing',
        '2.3',
        'Method "%s()" is deprecated, use "%s()".',
        __METHOD__,
        'capture'
    );

    return $this->capture($amount);
}
Enter fullscreen mode Exit fullscreen mode

On the receiving end sits the PHPUnit bridge. It sorts every deprecation it sees into categories: self (triggered in your code), direct (triggered in a vendor package, called by your code), indirect (vendor calling vendor), and other. You set thresholds with the SYMFONY_DEPRECATIONS_HELPER environment variable, and the docs list max[total], max[self], max[direct] and max[indirect] as keys:

<php>
  <env name="SYMFONY_DEPRECATIONS_HELPER"
       value="max[self]=0&amp;max[direct]=0"/>
</php>
Enter fullscreen mode Exit fullscreen mode

That setting fails the build on deprecations you can fix and tolerates vendor-to-vendor noise you cannot. If you inherit a pile of them, the bridge can write a baseline with generateBaseline=true&baselineFile=./tests/allowed.json and later fail only on new ones. The major-upgrade guide builds its whole procedure on this: move to the last minor, make the code deprecation-free, then bump the major.

The direct count is a proxy for something structural: how many places in your code call Symfony at all.

The number that predicts your upgrade bill

Two measurements. The first one takes ten seconds:

# every PHP file under src/ that imports Symfony
grep -rlE '^use Symfony\\' src | wc -l

# the same, minus the adapter folder
grep -rlE '^use Symfony\\' src \
  | grep -v '^src/Infrastructure/' \
  | wc -l
Enter fullscreen mode Exit fullscreen mode

Swap src/Infrastructure/ for wherever your adapters live. The second number is the one that matters. Every file it counts is a file that a removal like the Serializer Annotation aliases can reach, and every one of those files sits next to business rules. A controller that breaks is a one-line fix. An entity that breaks is a one-line fix plus a reviewer asking whether the business logic around it still holds.

The grep is honest but crude. It misses inline fully qualified names like new \Symfony\Component\..., and the default skeleton's src/Kernel.php will show up because it extends a Symfony class. Treat it as a starting count.

The second measurement turns that count into a rule. Deptrac (composer require --dev deptrac/deptrac) assigns classes to layers with collectors and fails when a layer depends on one it is not allowed to see. The collectors reference documents directory for your own folders and classNameRegex for matching class names, which is enough to put Symfony itself in a layer:

deptrac:
  paths:
    - ./src
  layers:
    - name: Domain
      collectors:
        - type: directory
          value: src/Domain/.*
    - name: Application
      collectors:
        - type: directory
          value: src/Application/.*
    - name: Adapters
      collectors:
        - type: directory
          value: src/Infrastructure/.*
    - name: Symfony
      collectors:
        - type: classNameRegex
          value: '#^Symfony\\.*#'
  ruleset:
    Domain: ~
    Application:
      - Domain
    Adapters:
      - Application
      - Domain
      - Symfony
Enter fullscreen mode Exit fullscreen mode

Run it with vendor/bin/deptrac analyse --config-file=deptrac.yaml (the tool's default config file is now deptrac.php, so name the YAML one explicitly). Domain: ~ means the domain may depend on no other layer, Symfony included. The violation list on the first run is roughly your grep count with file and line numbers attached. Wire it into CI and that number can only go down.

A black hexagon with a plain cream core and burnt orange framework pieces bolted only to its outer ring
Framework code on the outer ring. The core never imports it.

Framework at the edge, treadmill at the edge

Take the Serializer removal and run it through both layouts.

In the coupled layout, the Order entity carries #[Groups(['order:read'])] imported from the old Annotation namespace. The 8.0 upgrade touches your domain model, and so does every future change to how serialization metadata works.

In the decoupled layout, App\Domain\Order\Order is a plain PHP class. The HTTP adapter maps it to a response DTO, and the DTO is the only thing that knows the Serializer exists:

namespace App\Infrastructure\Http\Order;

use App\Domain\Order\Order;
use Symfony\Component\Serializer\Attribute\Groups;

final class OrderResponse
{
    #[Groups(['order:read'])]
    public string $id;

    #[Groups(['order:read'])]
    public int $totalCents;

    public static function from(Order $o): self
    {
        $r = new self();
        $r->id = $o->id()->toString();
        $r->totalCents = $o->total()->cents();
        return $r;
    }
}
Enter fullscreen mode Exit fullscreen mode

The Attribute\Groups import is the one the current Serializer docs use. The same goes for the Url default flip: put the constraint on the input DTO in your adapter, pass requireTld explicitly, and the domain's own WebsiteUrl value object keeps whatever rule your business actually has. Symfony changing a default stops being a domain decision.

None of this makes upgrades free. Config formats, bundles and recipes still move, and the adapters still need work. What changes is the shape of the work: a bounded folder you can upgrade on its own, against a domain test suite that does not import Symfony and therefore cannot be broken by Symfony API changes.

The common reason to sit on an LTS is that each minor feels like a gamble across the whole codebase. If the Deptrac rule holds and the grep count outside adapters is near zero, each minor is a change to one layer with a deprecation gate in front of it. At that point, following 8.1, 8.2 and onwards every six months is a routine chore, and picking the LTS just means scheduling that chore less often.

Run the grep today. If the second number is in the hundreds, you already know which track you can afford.


If this was useful

The grep in this post is the cheap version of an idea I spent a whole book on. Decoupled PHP walks through putting Symfony and Laravel at the edge of a real application, behind ports and adapters, so the next major is an adapter job instead of a rewrite.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)