/

Symfony 7: the upgrade that took an afternoon, and the one that took a month

1,880 words, about 8 min read

Composer wrote symfony/framework-bundle 7.0 into our lock file on Wednesday 31 January, and the only thing that broke in production was a serializer class nobody had opened since 2019. That is the honest headline. The version bump took an afternoon. The work that made the afternoon possible took a month, and it had nothing to do with 7.0 itself.

We are a backend team on a long-running booking and tracking platform. We sat on 6.2 for most of last year, moved to 6.4 in December, and went to 7.0 at the end of January. Two jobs, two very different price tags, and most upgrade guides bill them as one.

Two releases, one feature set, very different support windows

Symfony 7.0.0 was tagged on 29 November 2023, at the same time as 6.4. The upgrade guide opens by saying the two versions have the same features and 7.0 simply does not include the deprecated ones. So going to 7.0 is not a feature migration. It is a deprecation cleanup you perform on 6.4, where everything still works and complains loudly, followed by a one line change to composer.json.

The requirements do differ. Symfony 7.0 needs PHP 8.2.0 or higher; 6.4 runs on PHP 8.1.0. We were on 8.1, so PHP came first. That argument was easy to make to ops: PHP 8.1 left active support in November 2023 and now only receives security fixes until 31 December 2025.

The support maths is the part I would actually think about before copying us. Per the release process, a standard version gets bug fixes and security fixes for eight months, while an LTS gets three years of bug fixes and four years of security fixes. In practice that means 6.4 is maintained until November 2026 with security patches into November 2027, and 7.0 support ends in July 2024. Five months from today. Choosing 7.0 is choosing to upgrade again in the middle of the year.

The afternoon

On 6.4 our suite reported 2,317 deprecation notices that collapsed into 38 distinct messages. Almost all of them were find and replace with a bit of care:

  • Command::$defaultName and $defaultDescription to the #[AsCommand] attribute, 43 commands
  • MessageHandlerInterface and MessageSubscriberInterface to #[AsMessageHandler], 61 handlers
  • AbstractController::renderForm() back to plain render() with the form passed as a variable, 118 call sites
  • Doctrine event subscribers to #[AsDoctrineListener], 7 classes, since the Doctrine bridge dropped subscriber support
  • ContainerAwareInterface and ContainerAwareTrait in 9 services old enough to remember Symfony 3
  • Request::getContentType() renamed to getContentTypeFormat(), plus translation:update renamed to translation:extract in our Makefile

Then the types. Symfony has been adding native type declarations across three major versions, and 7.0 was the end of that effort: return types on everything skipped in 6.0, void where applicable, and types on all class properties including public and protected ones. The framework ships a patcher for this, which is genuinely good:

# the -o matters, it forces Composer to find all classes
composer dump-autoload -o
./vendor/bin/patch-type-declarations

It rewrote 211 method signatures in src/ without me reading them, and I reviewed maybe a dozen of them before deciding the tool was more careful than I was being at that hour. Where it could not help was our own code implementing framework interfaces. Two of our normalizers declared supportsNormalization($data, ?string $format = null), and 7.0 adds an array $context = [] parameter to that method on NormalizerInterface. Those you fix by hand, per class.

The month: 704 route annotations

The real cost was annotations. Symfony 7.0 removes the Doctrine annotations integration from FrameworkBundle outright, and with it AnnotationClassLoader, AnnotationDirectoryLoader and AnnotationFileLoader in Routing, AnnotationLoader in Validator and Serializer, and the routing.loader.annotation services. The config keys go too: framework.validation.enable_annotations and framework.serializer.enable_annotations became enable_attributes.

We had 168 controller classes carrying 704 route annotations, 91 entities with docblock ORM mapping, and roughly 260 validation constraints living in comments. The conversion is mechanically simple:

// before, and this stops working entirely on 7.0
/**
 * @Route("/shipments/{reference}", name="shipment_show", methods={"GET"})
 */
public function show(string $reference): Response
{
    // ...
}

// after
use SymfonyComponentRoutingAnnotationRoute;

#[Route('/shipments/{reference}', name: 'shipment_show', methods: ['GET'])]
public function show(string $reference): Response
{
    // ...
}

Note the import. On 7.0 the class you use as a native attribute is still SymfonyComponentRoutingAnnotationRoute, which the upgrade guide says explicitly, and 6.4 also added aliases for the whole Annotation namespace under Attribute. Both resolve. The word “annotation” in the namespace of a native attribute is a wart, and I assume it gets sorted later.

Rector did most of the conversion. 39 files needed hands: multi-line routes with defaults and requirements arrays, and a handful of @AssertExpression constraints whose quoting did not survive the automatic pass. The PR was 1,412 files. It took three weeks of calendar time, not because the work was hard but because it had to be rebased against normal feature work twice a week and read by someone other than me. Total human time was closer to 90 hours across three of us. If you are sizing this for your own app, count route annotations and entities, not controllers.

The class that could not load

Deploy day. The cache warmup step in the production image failed with this, which I am copying out of the deploy log:

PHP Fatal error:  Class AppSerializerShipmentNormalizer cannot extend
final class SymfonyComponentSerializerNormalizerObjectNormalizer in
/srv/app/src/Serializer/ShipmentNormalizer.php on line 16

Ten of the Serializer normalizers, ObjectNormalizer among them, became final in 7.0, and that was announced in the 6.3 changelog. The deprecation notice for it never appeared in our test run because that normalizer is only registered when our partner API bundle is enabled, and the test environment does not enable it. That is our fault, not the framework’s, and we have since added a second CI job that boots the prod container with every bundle on.

The fix is decoration instead of inheritance, and 7.0 also requires getSupportedTypes() on the normalizer interfaces, a method that arrived in 6.3 when CacheableSupportsMethodInterface was deprecated:

use SymfonyComponentDependencyInjectionAttributeAutowire;
use SymfonyComponentSerializerNormalizerNormalizerInterface;

class ShipmentNormalizer implements NormalizerInterface
{
    public function __construct(
        #[Autowire(service: 'serializer.normalizer.object')]
        private readonly NormalizerInterface $objectNormalizer,
    ) {
    }

    public function normalize(mixed $object, ?string $format = null, array $context = []): array
    {
        $data = $this->objectNormalizer->normalize($object, $format, $context);
        $data['tracking_url'] = $this->trackingUrl($object);

        return $data;
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Shipment;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [Shipment::class => true];
    }
}

Two smaller ones from the same week. HttpFoundation no longer silently coerces bad input: ParameterBag::getInt() and getBoolean() throw instead of falling back to 0 and false, and on InputBag that surfaces as a 400. An old notification email of ours linked to the tracking list with an empty ?page=, so we logged 311 of these in the first two hours:

Input value "page" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.

I like this change, for the record. It found a real bug in a link we had been sending for years. And the default of framework.http_method_override flipped from true to false, which caught our partner retry form in staging with No route found for "POST https://booking.test/webhooks/partner/2/retry": Method Not Allowed (Allow: PUT). Setting it back is one line, but read the flip as a hint and stop relying on _method.

AssetMapper, and the node_modules we deleted

The thing I actually wanted from this cycle shipped before 7.0. AssetMapper arrived in 6.3 as experimental and was marked non experimental in 6.4, which is also when it gained CSS support in the importmap, an entrypoints concept, local package downloads instead of a CDN, and importmap:audit and importmap:outdated commands. You run php bin/console importmap:require bootstrap, it writes a version into importmap.php, and asset-map:compile copies everything into public/assets/ at deploy time. No bundler.

For the customer tracking pages that was the whole story. Encore’s webpack build was 2 minutes 41 seconds of a 7 minute 14 second pipeline, and node_modules was 412 MB in the builder stage. Both are gone, CI is down to 4 minutes 33 seconds, and I no longer get to blame a JavaScript toolchain for a failed deploy.

The admin side still runs Encore, because our stylesheets are Sass and AssetMapper does not compile Sass; the docs point you at a separate bundle for that. Encore is not deprecated and the same docs still recommend it for very old browsers and heavy JSX. So we run both, which is not a state of affairs I enjoy describing out loud.

What we picked up from 6.3 and 6.4 along the way

#[MapRequestPayload] and #[MapQueryString] both landed in 6.3, and 6.4 added a validationFailedStatusCode argument to them. This deleted a layer of hand written form handling from our API:

use SymfonyComponentHttpKernelAttributeMapRequestPayload;

#[Route('/api/bookings', name: 'api_booking_create', methods: ['POST'])]
public function create(
    #[MapRequestPayload(validationFailedStatusCode: 400)] BookingRequest $payload,
): JsonResponse {
    $booking = $this->bookings->create($payload);

    return $this->json(['reference' => $booking->reference()], 201);
}

The default failure status is 422. We pass 400 on the partner endpoints because two of our couriers treat anything other than 400 as a transport error and retry it forever, which is its own article.

The Clock component was added in 6.2, gained the Clock class, the now() function and ClockAwareTrait in 6.3, and DatePoint in 6.4. Swapping our cutoff calculator onto ClockAwareTrait and MockClock deleted a 140 line time provider we wrote in 2019 and the two tests that existed to test the provider rather than the cutoff. Scheduler and Webhook both came in 6.3 as experimental and were marked non experimental in 6.4. We have moved 4 of 19 cron entries into a schedule and the other 15 stay in crontab until I trust one worker with them. Webhook we have not adopted at all, because our courier callback parsers predate it and work.

One correction to something I said internally in December: the Notifier improvements in this cycle are in the bridges, not the component. The component changelog has no 6.4 or 7.0 entry at all.

Would I take 7.0 over staying on 6.4

If your team can absorb a minor upgrade every six months, take 7.0. The cleanup is paid for once you have resolved deprecations on 6.4, and running with no deprecated code paths under you is a quieter life. If your releases are quarterly, or your PHP version is decided by a hosting provider, stay on 6.4 until 2026 and lose nothing: the features are identical, which is the entire point of the release model. The only wrong answer is sitting on 7.0 in July when the fixes stop.

Still open on my list: the Sass situation, which means the Encore config is still in the repo and still in the Dockerfile. And those 39 hand-converted route attributes, where I want a second pair of eyes on the requirements arrays before we go anywhere near the next upgrade.

Sources

  • Symfony 7.0 release page: PHP 8.2.0 minimum, November 2023 release, support ending July 2024.
  • Symfony 6.4 release page: LTS status, PHP 8.1.0 minimum, bug fixes to November 2026 and security fixes to November 2027.
  • UPGRADE-7.0.md: the removals quoted here, including annotation support, renderForm(), getContentType(), Doctrine subscribers, and the changed config defaults such as http_method_override.
  • Symfony release process: eight months of support for a standard version against three and four years for an LTS.
  • Symfony 7.0 Type Declarations: what the type work covers and the patch-type-declarations command.
  • Serializer changelog: getSupportedTypes() in 6.3, the normalizers that became final in 7.0, the added $context argument.
  • HttpKernel changelog: #[MapRequestPayload] and #[MapQueryString] in 6.3, validationFailedStatusCode in 6.4.
  • AssetMapper changelog: experimental in 6.3, non experimental in 6.4, plus the CSS, entrypoint and local download changes.
  • AssetMapper documentation: importmap:require, asset-map:compile, and the separate bundle needed for Sass.
  • Clock changelog: component in 6.2, now() and ClockAwareTrait in 6.3, DatePoint in 6.4.