KwikTech Symfony · API Platform · Doctrine upgrades
Upgrade guide · 11 min read

Upgrading Symfony 6.4 to 7.4: deprecations, return types and the jump

How to get a 6.4 application to 7.4 LTS in one release: deprecation hunting with the PHPUnit bridge, patching return types, what 7.0 removed, and the composer update.

Published 2026-09-12. Written for teams doing the upgrade themselves; if you would rather have it done, see fixed-price upgrades. Also available in Deutsch.

What 7.0 actually changed

Symfony 7.0 is 6.4 with the deprecated code paths removed. There is no new architecture to learn. Every removal was announced as a deprecation in a 6.x release, so a 6.4 application whose test run reports zero deprecations upgrades to 7.x with a composer command. That statement is precise: the work is not "upgrading to 7", it is "getting 6.4 to zero deprecations". 7.4 is the LTS target (bug fixes until November 2028) and it requires PHP 8.2 or newer.

Step 1: PHP 8.2, ideally 8.3 or 8.4

Symfony 6.4 runs on PHP 8.1 to 8.5, so bump PHP first, on 6.4, in its own PR. The PHP change that touches the most code is 8.4's deprecation of implicitly nullable parameters:

// deprecated in PHP 8.4
public function __construct(LoggerInterface $logger = null) {}

// correct
public function __construct(?LoggerInterface $logger = null) {}

Rector's LevelSetList::UP_TO_PHP_84 (or withPhpSets()) rewrites these. Dynamic properties (deprecated in 8.2) show up as "Creation of dynamic property" notices in the test run; declare the property or the class is not doing what you think.

Step 2: the deprecation report is the checklist

# phpunit.xml.dist
<env name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0&amp;max[indirect]=999999"/>

Run the suite. Anything under "direct" is your code calling something 7.0 removes. Common items on a 6.4 codebase:

DeprecationReplacement
Symfony\Component\Security\Core\SecuritySymfony\Bundle\SecurityBundle\Security (same methods, moved in 6.2)
Request::getContentType()Request::getContentTypeFormat()
Annotation-based routing (type: annotation)type: attribute, #[Route]
Symfony\Component\Serializer\Annotation\*Symfony\Component\Serializer\Attribute\* (Groups, Ignore, MaxDepth, SerializedName)
Symfony\Component\Validator\Constraints\* used as annotationsSame classes as attributes: #[Assert\NotBlank]
Symfony\Component\HttpKernel\Event\KernelEvent::isMasterRequest()isMainRequest()
Doctrine\Common\Annotations\AnnotationRegistry::registerLoader()Delete; nothing needs it with attributes
Form getBlockPrefix() without return type, DataTransformerInterface without typesAdd the return and parameter types
framework.http_method_override: true assumedSet it explicitly; the default changed
Symfony\Component\HttpFoundation\File\UploadedFile::getClientSize(), Response::create(), Yaml::PARSE_KEYS_AS_STRINGS …Removed in 6.0 already; if you see them you are not on 6.4

The "indirect" bucket is third-party code deprecating against other third-party code. It does not block 7.4 by itself, but each entry points at a bundle that needs its next major.

Step 3: Rector, again

// rector.php
return RectorConfig::configure()
    ->withPaths([__DIR__.'/src', __DIR__.'/tests'])
    ->withPhpSets(php84: true)
    ->withSets([
        SymfonySetList::SYMFONY_70,
        SymfonySetList::SYMFONY_71,
        SymfonySetList::SYMFONY_72,
        SymfonySetList::SYMFONY_73,
        SymfonySetList::SYMFONY_74,
        SymfonySetList::SYMFONY_CODE_QUALITY,
        SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION,
    ])
    ->withSymfonyContainerXml(__DIR__.'/var/cache/dev/App_KernelDevDebugContainer.xml');

The 7.x sets are small compared to the 6.x ones. Most of the diff will be the serializer attribute namespace, Security class moves and a few renamed constants. Run with --dry-run, apply, test, commit as its own PR.

Step 4: return types, last call

If the project skipped patch-type-declarations during the 6.x upgrade, do it now. In 7.0 the types are on the interfaces for real, and a missing return type on an implementation is a fatal error at container compile time, not a deprecation:

SYMFONY_PATCH_TYPE_DECLARATIONS='force=2&php=8.2' vendor/bin/patch-type-declarations
bin/console lint:container

Step 5: dependencies that cap at 6.4

Run a dry update to see who refuses:

composer config extra.symfony.require "7.4.*"
composer update "symfony/*" --with-all-dependencies --dry-run

Composer names the package that pins symfony/* to ^6.x. The usual suspects and what to do:

  • doctrine/doctrine-bundle 2.x supports 7.x from 2.11; DoctrineBundle 3 needs ORM 3. See the ORM guide.
  • api-platform/core 3.2+ supports 7.x; 4.x is where you want to be. See API Platform 3 to 4.
  • lexik/jwt-authentication-bundle 3.x, knplabs/knp-paginator-bundle 6.x, nelmio/cors-bundle 2.4+, easycorp/easyadmin-bundle 4.8+ (5.x for 8.x support). The compatibility pages list the first release of each bundle that allows 7.4.
  • Anything abandoned (SensioFrameworkExtra, Guard, Swiftmailer bundle) has no 7.x release and must go before the bump. The abandoned bundles guide covers each.
  • Your own private packages pinning symfony/http-kernel: ^6.0 in their composer.json. Widen to ^6.4 || ^7.0 and tag them first.

Step 6: the bump

composer update "symfony/*" --with-all-dependencies
composer recipes:update
bin/console cache:clear
bin/console lint:container && bin/console lint:twig templates && bin/console lint:yaml config
bin/console debug:router | head          # sanity
vendor/bin/phpunit

Post-bump items that are not deprecations and therefore never showed up in the report:

  • config/packages/*.yaml: recipes for 7.x moved a few keys; recipes:update shows the diff per package. Accept, then re-apply your own values.
  • Twig: 7.x pulls Twig 3.10+, which deprecates {% filter %} in favour of {% apply %} and spaceless. lint:twig reports them.
  • symfony/phpunit-bridge and PHPUnit: 7.4 works with PHPUnit 9 through 13; if you move to PHPUnit 12+, annotations become attributes there as well (Rector's PHPUnitSetList handles it).
  • Messenger transports: the failed transport and retry strategy config format did not change; the Doctrine transport table gets an extra index in a migration, generate it with doctrine:migrations:diff.

After 7.4: keeping the option to go to 8.x

Symfony 8.0 was released together with 7.4 and, once more, only removes what 7.4 deprecates. Keep max[self]=0 in the bridge configuration and fix new deprecations as 7.x minors land. 8.x then becomes a version bump when a bundle asks for it, not a project. Standard 8.x releases get fixes for eight months, so for an application you do not touch monthly, staying on 7.4 LTS is the sensible default.

Order of the pull requests

  1. PHP 8.3 or 8.4 on 6.4
  2. Rector 7.x sets and PHP sets
  3. Return types patched, direct deprecations to zero
  4. Bundle majors that block 7.x (each its own PR where the API changed)
  5. Composer bump to 7.4, recipes, lint, tests

For a clean 6.4 codebase this is typically one to two weeks of focused work, which is where the €3,500 to €6,000 fixed price comes from. The parts that move it towards the upper end are ORM 2, API Platform 3 and a thin test suite.

Find out which packages pin you to 6.4

The scan reads your composer.lock and shows, per package, whether the installed and the latest release allow Symfony 7.4, plus the cliffs (ORM 2, API Platform 3, PHPUnit below 12) that turn the bump into a project.

Scan my project Request an assessment (€750)

More guides