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&max[direct]=0&max[indirect]=999999"/>
Run the suite. Anything under "direct" is your code calling something 7.0 removes. Common items on a 6.4 codebase:
| Deprecation | Replacement |
|---|---|
Symfony\Component\Security\Core\Security | Symfony\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 annotations | Same 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 types | Add the return and parameter types |
framework.http_method_override: true assumed | Set 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.0in their composer.json. Widen to^6.4 || ^7.0and 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:updateshows 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 %}andspaceless.lint:twigreports them.symfony/phpunit-bridgeand PHPUnit: 7.4 works with PHPUnit 9 through 13; if you move to PHPUnit 12+, annotations become attributes there as well (Rector'sPHPUnitSetListhandles it).- Messenger transports: the
failedtransport and retry strategy config format did not change; the Doctrine transport table gets an extra index in a migration, generate it withdoctrine: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
- PHP 8.3 or 8.4 on 6.4
- Rector 7.x sets and PHP sets
- Return types patched, direct deprecations to zero
- Bundle majors that block 7.x (each its own PR where the API changed)
- 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.
More guides
- Upgrading Symfony 5.4 to 6.4: the complete guide
- API Platform 2.6 to 3: migrating resources, data providers and persisters
- API Platform 3 to 4: the state-based flow, RFC 7807 errors and Hydra changes
- Upgrading Doctrine ORM 2 to 3 (and DBAL 3 to 4) in a Symfony application
- Replacing abandoned Symfony bundles: SensioFrameworkExtra, Guard, Swiftmailer, FOSRest, FOSUser