Versions Symfony 5 → 7
TL;DR — Trois sauts à connaître : 5.4 → 6.4 (LTS to LTS, low-risk si tu as fait le ménage des deprecations), 6.4 → 7.x (cycle 8 mois, minor breaking annuel), PHP 7.4 → 8.x (typage strict, readonly, attributs). Méthode : (1) bumper PHP, (2) zéro deprecation sur la version actuelle, (3) bumper Symfony une majeure à la fois, jamais sauter. Outils :
symfony-cli check:requirements,rectoravec setsymfony-code-quality, deprecation reports PHPUnit.
Le modèle mental : la migration est gratuite si tu paies au fur et à mesure
La seule chose à comprendre pour bien migrer Symfony, c'est le contrat de compatibilité (BC promise) et son corollaire : une deprecation aujourd'hui = un breaking change à la prochaine majeure.
N.0 N.1 N.2 N.3 N.4 (LTS) (N+1).0
API stable ─────────●─────────────●─────●─────●─────●──────────────────●
│ ▲ │ │
deprecation │ (on peut deprecate │ CE QUI ÉTAIT │
introduite ─────────┘ à n'importe quelle │ DEPRECATED EN │
mineure ≥ N.0) │ N.x DEVIENT │
│ FATAL ICI ────┘Les invariants du SemVer Symfony (à réciter en entretien) :
- Aucun breaking change dans une mineure (
6.3 → 6.4). On peut seulement ajouter du code et déprécier de l'ancien. Un upgrade mineur ne casse jamais le runtime — au pire il pollue tes logs deUser Deprecated. - Une deprecation N'EST RETIRÉE qu'à la majeure suivante (
6.x → 7.0). Donc une6.4100% sans deprecation = une7.0qui démarre sans broncher. C'est ça, la stratégie : la dernière mineure d'une majeure (la LTS) est ton tremplin. - La LTS et la première version de la majeure suivante sortent le MÊME jour (5.4 et 6.0 en nov. 2021 ; 6.4 et 7.0 en nov. 2023). Elles partagent le même code, à la couche de compat-deprecation près. C'est voulu : ça rend le saut LTS→LTS+1.0 mécanique.
Comment un staff engineer raisonne : il ne pense pas « migrer de 5.4 à 7 ». Il pense « mettre à zéro le compteur de deprecations sur chaque palier, puis le bump est un non-événement ». La migration coûteuse, c'est celle qu'on a différée : du code écrit en 5.0, jamais nettoyé, qui accumule 4 ans de dette d'API. Le travail réel n'est pas le composer require — c'est le grep des User Deprecated et le refactor en amont. La règle business à vendre à ton management : rester sur la mineure courante en continu coûte ~0 ; rattraper 3 majeures de retard coûte un trimestre.
| Axe | Mineure (6.3→6.4) | Majeure (6.4→7.0) | PHP (7.4→8.2) |
|---|---|---|---|
| Runtime peut casser ? | Non (BC promise) | Oui (deprecations retirées) | Oui (signatures, mixed, erreurs internes) |
| Effort si à jour | composer update | 1–2 jours | 1–2 semaines |
| Effort si en retard | logs noyés | semaines/mois | mois |
| Rector aide ? | Partiel | Oui (sets SYMFONY_xx) | Oui (LevelSetList) |
| Le vrai risque | — | bundles tiers non maintenus | extensions C, libs natives |
Compatibilité PHP
| Symfony | PHP min | PHP max testé | EOL Symfony |
|---|---|---|---|
| 4.4 LTS | 7.1.3 | 8.0 | Nov 2023 (mort) |
| 5.0 | 7.2.5 | 7.4 | Juil 2020 |
| 5.4 LTS | 7.2.5 | 8.x | Nov 2025 (fin support) — 2029 (sécu pro) |
| 6.0 | 8.0.2 | 8.1 | Jan 2023 |
| 6.2 | 8.1 | 8.2 | Mai 2023 |
| 6.3 | 8.1 | 8.2 | Janv 2024 |
| 6.4 LTS | 8.1 | 8.3+ | Nov 2027 — 2032 (sécu pro) |
| 7.0 | 8.2 | 8.3 | Juil 2024 |
| 7.1 | 8.2 | 8.3 | Janv 2025 |
| 7.2 | 8.2 | 8.4 | Juil 2025 |
| 7.3 | 8.2 | 8.4 | Janv 2026 |
| 7.4 (à venir LTS) | 8.2+ | 8.4+ | TBD (2028+) |
Règle pratique : pour une appli en prod, viser une LTS (5.4, 6.4, 7.4) et bumper PHP en amont. PHP 8.2 est le minimum confortable en 2026, PHP 8.3 recommandé (perf + types readonly clean).
Symfony 5.4 LTS — état des lieux
5.4 sort en novembre 2021. C'est la dernière version 5.x. Caractéristiques :
- Support standard termine en novembre 2025.
- Support sécurité termine en novembre 2025 côté open source, mais SensioLabs/Symfony Corporate Support étend jusqu'en novembre 2028-2029 (payant).
- C'est la version de parking si tu ne peux pas migrer immédiatement vers 6.4. Au-delà, tu paies ou tu migres.
- Support PHP 7.2.5+ jusqu'à 8.x. Donc tu peux tourner sous PHP 8.2/8.3 avec Symfony 5.4 (utile pour gagner en perf sans bumper Symfony).
- Toutes les fonctionnalités modernes (Messenger, Notifier, Mailer, Webhook, Lock, Cache, RateLimiter, Workflow) sont présentes en 5.4.
Pourquoi 5.4 est "safe parking" : pas de bouleversement majeur d'API depuis 5.0, l'écosystème de bundles est mûr. Mais le compteur tourne : chaque mois passé en 5.4 c'est du retard à rattraper.
Symfony 5.x → 6.x
Stratégie : passer en 5.4 d'abord (cumule les deprecations finales 5.x), corriger toutes les deprecations, puis bump vers 6.0 → 6.4.
Pré-requis PHP
PHP minimum 8.0.2. Si tu es en 7.4, c'est ton premier chantier — pas optionnel.
Routing
- Annotations Doctrine (
@Route("/foo")) → attributs PHP 8 (#[Route('/foo')]). Les annotations restent supportées en 6.x viadoctrine/annotations, mais 7.0 retire le support. Symfony\Component\Routing\Annotation\Route→ use ce namespace (pasSensio\Bundle\FrameworkExtraBundle).FrameworkExtraBundle(SensioFrameworkExtraBundle) déprécié : ses fonctionnalités sont intégrées au core Symfony (#[Route],#[ParamConverter]→#[MapEntity],#[IsGranted],#[Cache]).
Controllers (AbstractController)
AbstractController::dispatchMessage()supprimé (était deprecated 5.4). UtiliseMessageBusInterfaceinjecté.AbstractController::get('foo')(service locator legacy) → injecte le service via constructeur.getDoctrine()supprimé en 6.0. InjecteEntityManagerInterfaceouManagerRegistry.$this->getUser()retourneUserInterface|null(déjà 5.x, mais plus de magic mixed).
Security (réécriture majeure)
C'est LE chantier de la migration 5.x → 6.x.
- Nouveau système d'authentification :
AuthenticatorremplaceGuardAuthenticator. Active viaenable_authenticator_manager: true(par défaut en 6.0). GuardAuthenticationHandler,AbstractGuardAuthenticator→ supprimés.- Custom authenticators : extends
Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator. User\UserInterface::getUsername()→getUserIdentifier().EquatableInterface,PasswordAuthenticatedUserInterfaceajoutés pour des cas précis.- Voters : signature inchangée, mais
Voter::supportsdoit retournerbool.
Messenger
MessageHandlerInterfacedéprécié en 5.4, supprimé en 7.0. Remplace par l'attribut#[AsMessageHandler]sur la classe (ou méthode).Workerne broadcast plus certains events legacy.- Stratégie retry : explicite via transport config (
retry_strategy.max_retries).
// AVANT (5.x)
final class SendEmailHandler implements MessageHandlerInterface {
public function __invoke(SendEmail $msg): void { /* ... */ }
}
// APRÈS (6.x)
#[AsMessageHandler]
final class SendEmailHandler {
public function __invoke(SendEmail $msg): void { /* ... */ }
}Doctrine
- DoctrineBundle 2.7+ requis.
- Doctrine ORM 2.x toujours compatible, mais préparer 3.x (voir doctrine/orm 3.0 — drops
Doctrine\Common\Annotationscôté metadata, dropsDoctrine\Common\Cache). Doctrine\ORM\EntityManager→ typehintEntityManagerInterface.@ORM\Entityannotation →#[ORM\Entity]attribute (annotations dépréciées en Doctrine ORM 2.16+).
Event listeners
- Service tag
kernel.event_listener→ attribut#[AsEventListener]sur la classe ou méthode.
#[AsEventListener(event: KernelEvents::REQUEST, priority: 10)]
final class LocaleListener {
public function __invoke(RequestEvent $event): void { /* ... */ }
}Autowiring & DI
- Removal of
Symfony\Bundle\FrameworkBundle\Controller\AbstractController::containeraccess (was protected mixed) → use constructor injection. - Service IDs sensibles à la casse (étaient case-insensitive).
Symfony\Component\DependencyInjection\ContainerInterface::has()retourneboolstrict.
Session
framework.session.storage_id→framework.session.storage_factory_id(factory pattern, requis pour avoir une session par requête en mode worker).SessionInterfaceinjectable via$session = $request->getSession(). Ne plus injecterSessionInterfacedirectement (deprecated).
HttpClient & Mailer
- Mailer :
MailerInterfacestable. DSN avec scheme dédié (smtp+oauth,gmail,ses+api). - HttpClient :
ScopingHttpClientpour multi-config par host.
Twig / Form
- Twig 3.x requis (Twig 2.x EOL).
- Form types :
ChoiceTypeaccepte des callables pourchoice_label. Quelques options renommées.
Liste de deprecations clés à fixer
# Lister les deprecations dans le code
bin/console debug:container --deprecations
bin/phpunit --display-deprecations # PHPUnit 10+Cherche dans var/log/dev.deprecations.log après une session de tests. Toute deprecation 5.4 sera erreur fatale en 6.0.
Symfony 6.x → 7.x
7.0 sort en novembre 2023. Le saut est plus petit que 5 → 6 si tu es à jour des deprecations 6.4.
Pré-requis PHP
PHP minimum 8.2. Bumper PHP avant.
Annotations Doctrine retirées
doctrine/annotationsn'est plus une dépendance du squelette Symfony 7. Si tu utilises encore@Route,@ORM\Entity, etc., installedoctrine/annotationsmanuellement ou migre vers attributes.
SensioFrameworkExtraBundle EOL
- Le bundle est archived (depuis 2023). Ses fonctionnalités sont dans Symfony core :
@ParamConverter→#[MapEntity],#[MapRequestPayload],#[MapQueryString].@IsGranted→#[IsGranted](déjà core en 6.2).@Cache→#[Cache](HttpKernel).@Template→ pas de remplaçant officiel ; retourne explicitement$this->render(...).
Security
Symfony\Bundle\SecurityBundle\Security(nouveau) remplaceSymfony\Component\Security\Core\Security(deprecated).getUser(),isGranted(),getToken()toujours là mais via le nouveau service.
// AVANT (6.x)
use Symfony\Component\Security\Core\Security;
class X { public function __construct(private Security $sec) {} }
// APRÈS (7.x)
use Symfony\Bundle\SecurityBundle\Security;
class X { public function __construct(private Security $sec) {} }Messenger
- Strategy retry et failure transports plus stricts.
failure_transportrecommandé partout. - Suppression de
MessageHandlerInterfaceetMessageSubscriberInterface→ uniquement#[AsMessageHandler].
Serializer
getSupportedTypes()requis pour bénéficier du cache (sinon perte de perf). Custom normalizers doivent l'implémenter.ObjectNormalizerstricter sur les types.
Validator
- Certaines contraintes legacy (
Length::$strictetc.) supprimées. Préfère les contraintes modernes.
HttpKernel / Routing
RouterInterface::generate()lanceRouteNotFoundException(déjà 6.x) — mais types stricts.Request::getSession()lanceSessionNotFoundExceptionsi pas de session configurée (avant : retournaitnullparfois).
Form
Symfony\Component\Form\FormError::getMessage()toujours là. Quelques options de form types renommées.
DependencyInjection
- Suppression de l'auto-injection de certains services legacy. Le container compile plus strict.
ContainerBuilder::compile()lève sur des services circulaires (avant : warning silencieux dans certains cas).
Doctrine
- Symfony 7.0+ compatible Doctrine ORM 3.x (qui drop
Doctrine\Common\Cacheau profit de PSR-6). - DBAL 4.x compatible.
- Préparer la migration : Doctrine ORM 3.0 requiert PHP 8.1+ et drop pas mal d'API legacy.
Validator
- Suppression de
LegacyTranslatorProxy.
Stratégie de migration recommandée
État initial : Symfony 5.4 + PHP 7.4
Cible : Symfony 7.x + PHP 8.3Step-by-step playbook :
Verrouille les versions dans
composer.json("symfony/*": "5.4.*") pour éviter des bumps surprise pendant le chantier.Bumpe PHP 7.4 → 8.1 d'abord (Symfony 5.4 supporte 8.x). Fix les warnings PHP 8 (
$undefinedstrict,Stringable,mixedreturns). Lancephpstanniveau 6+.Élimine TOUTES les deprecations Symfony 5.4 :
- Active
SYMFONY_DEPRECATIONS_HELPER=max[total]=0en CI. - Lance la suite de tests : tout doit passer sans
User Deprecatedlog. - Rector :
vendor/bin/rector process src --set symfony-code-quality --set symfony54.
- Active
Bump Symfony 5.4 → 6.0 :
composer require "symfony/*:6.0.*"(puis update lock).- Fix les breaking changes (security, messenger, sessions).
- Roule la suite de tests. Corrige.
Monte progressivement 6.0 → 6.4 (par version mineure) :
- 6.0 → 6.1 → 6.2 → 6.3 → 6.4. À chaque step, fix les nouvelles deprecations.
- À 6.4, tu es LTS : pause éventuelle.
Bumpe PHP 8.1 → 8.2 (requis pour Symfony 7).
Bump Symfony 6.4 → 7.0 :
- Fix breakings :
Securitynamespace, retrait de annotations Doctrine (si pas déjà migré),#[AsMessageHandler]partout.
- Fix breakings :
Suis le cycle 7.x : 7.1, 7.2... jusqu'à la prochaine LTS (7.4 attendue fin 2025/début 2026).
Règle d'or : ne saute jamais une majeure (5.4 → 7.0 direct = enfer). À chaque palier, vert sur la CI avant le suivant.
Délai réaliste : 1–3 mois homme pour un projet moyen (50k LOC, 100+ entities), selon l'état du code. Le plus gros bloc : Security 5 → 6.
Failure modes & concerns de production
La migration ne casse presque jamais à cause de Symfony lui-même (la BC promise est tenue). Elle casse à cause de ce qui gravite autour. Le tableau des morts qu'un staff engineer anticipe :
| Failure mode | Symptôme | Cause racine | Parade |
|---|---|---|---|
| Bundle tiers non maintenu | composer require refuse de résoudre | Le bundle plafonne symfony/*: ^6.4 | Fork + patch, ou composer.json replace, ou virer le bundle. Audite-les AVANT le bump (composer why-not symfony/framework-bundle 7.0). |
| Deprecation noyée | Tout est vert mais 7.0 explose au boot | Deprecation jamais loggée (chemin non couvert par les tests) | Ne fais pas confiance aux tests seuls : passe --display-deprecations ET fais tourner du trafic de prod en shadow (replay) sur la nouvelle version. |
| Container compilé plus strict | ServiceNotFoundException au démarrage, pas en dev | Autowiring case-sensitive / service private qui était public | Compile en CI avec APP_ENV=prod APP_DEBUG=0 bin/console cache:clear — c'est le seul test qui reproduit la prod. |
| Session en worker (Messenger/Swoole/RoadRunner) | Sessions qui fuient entre requêtes | storage_id (singleton) au lieu de storage_factory_id | Migre vers le factory pattern (cf. section Session). Obligatoire en runtime persistant. |
| Doctrine ORM 2 → 3 | merge()/refresh(lock) supprimés, erreurs metadata | ORM 3 drop l'API legacy en même temps que Symfony 7 | Découple les deux migrations. Ne bumpe pas ORM 3 dans la même PR que Symfony 7. |
| PHP 8.x semantics | TypeError runtime, null interdit | mixed implicite, @ qui ne masque plus les erreurs fatales | PHPStan niveau 8+ AVANT le bump PHP, pas après. |
| Recipes divergentes | Config qui ne fait plus rien, defaults changés | config/packages/*.yaml figé à l'ancien squelette | composer recipes:update puis diff manuel — ne merge jamais les recipes à l'aveugle. |
Observabilité de la migration — comment savoir que ça s'est bien passé en prod, pas juste en CI :
- Compteur de deprecations en prod : route le channel Monolog
deprecationvers un compteur (Prometheus/Datadog) sur l'ancienne version avant de bumper. Si le compteur n'est pas à zéro en prod, tu n'es pas prêt — peu importe ce que dit la CI. - Golden signals pendant la bascule : P95/P99 latence, taux d'erreur 5xx, RPS par endpoint. Bump = déploiement comme un autre → canary 1% → 10% → 50% → 100% avec rollback auto si
5xx > seuil. - Shadow traffic / dual-run : rejoue le trafic réel (mirroring nginx/Envoy, ou un sidecar) contre la nouvelle version sans servir les réponses. Compare les diffs. C'est ce qui attrape les deprecations des chemins froids.
- Feature flag sur les bascules risquées (ex. nouveau système Security) : tu veux pouvoir revenir sans redéployer.
Sécurité — la réécriture Security 5→6 est le moment où des trous apparaissent : un firewall mal porté, un access_control qui ne matche plus, un voter dont le supports() retourne maintenant bool strict et change de comportement. Couvre chaque firewall/voter par un test fonctionnel d'autorisation (403 attendu = 403 obtenu) avant ET après.
Outils
- symfony-cli :
symfony check:requirements,symfony composer recipes:update,symfony console about(affiche versions/env). - Rector : transformations automatiques de code. Sets utiles :
Rector\Symfony\Set\SymfonySetList::SYMFONY_60Rector\Symfony\Set\SymfonySetList::SYMFONY_64Rector\Symfony\Set\SymfonySetList::SYMFONY_70Rector\Symfony\Set\SymfonySetList::ANNOTATIONS_TO_ATTRIBUTESRector\Set\ValueObject\LevelSetList::UP_TO_PHP_82
- PHPStan + phpstan-symfony : type-checking strict, attrape les usages dépréciés.
- Psalm : alternative à PHPStan, avec son plugin Symfony.
- PHPUnit deprecation listener :
SYMFONY_DEPRECATIONS_HELPER=max[total]=0fait échouer la CI sur toute deprecation. bin/console about: version Symfony, PHP, extensions, kernel info.composer outdated -D "symfony/*": voir les paquets à bumper.- Symfony Insight / Recipe upgrade :
composer recipes:update <package>met à jour les fichiers de config générés (config/packages/*.yaml). - Upgrade Guide GitHub :
https://github.com/symfony/symfony/blob/X.0/UPGRADE-X.0.mdest la source de vérité. À lire avant chaque bump majeur.
# Workflow Rector typique
composer require rector/rector --dev
# rector.php
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withPhpSets(php82: true)
->withSets([
SymfonySetList::SYMFONY_64,
SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES,
]);
vendor/bin/rector process --dry-run # preview
vendor/bin/rector process # apply🎬 Cas d'usage concrets
Scénario 1 — Migration SaaS RH 5.4 → 6.4 LTS (PayFit, Lucca, Eurécia)
Un SaaS RH français servant 18 000 entreprises tourne sur Symfony 5.4 LTS. La fin du support EOM est annoncée (novembre 2025). L'équipe planifie une migration 5.4 → 6.4 LTS sur deux trimestres avec PHP 8.1 → 8.3. Étapes : (1) audit composer outdated + symfony check:security, repérage des bundles abandonnés (StofDoctrineExtensions → migration vers gedmo/doctrine-extensions) ; (2) Rector avec SymfonySetList::SYMFONY_60 puis SYMFONY_64 en passes successives, sur des branches Git séparées ; (3) migration annotations → attributs PHP 8 (10 000 fichiers, 4 jours avec Rector + relecture) ; (4) remplacement des Doctrine\Common\Annotations par natifs ; (5) refonte de la couche Security (de Guard vers les nouveaux Authenticators introduits en 5.3/6.0). Le risque principal : les bundles tiers (KnpPaginatorBundle, JMSSerializerBundle, LiipImagineBundle) — chacun audité version par version. Les fixtures Foundry (déjà adoptées) facilitent le rejeu de la suite de tests 12 000 cas. Migration livrée en 5 mois, zéro régression majeure, P95 amélioré de 8% grâce aux optimisations Doctrine 3.
Scénario 2 — Migration banque 6.4 → 7.x (Crédit Agricole, BPCE)
Une équipe core banking migre de Symfony 6.4 LTS vers Symfony 7.1 pour bénéficier de AssetMapper stabilisé et du nouveau Scheduler. La contrainte forte : PHP 8.2 minimum (vs 8.1 en 6.4), donc bump de la base PHP préalable sur 1 mois. La migration suit le pattern "patch progressif" : (1) bump composer vers ^7.0 package par package en chasse aux deprecations sur composer config minimum-stability stable + symfony/flex qui résout les conflits ; (2) suppression des Security\Core\User\UserInterface legacy (déprécié 5.3, supprimé 7.0) — passage à PasswordAuthenticatedUserInterface partout ; (3) refonte du système de logs : Monolog\Formatter\JsonFormatter adapté pour le nouveau format Symfony 7 ; (4) test extensif sur l'environnement pré-prod miroir de la prod, rejeu de 50 000 requêtes en shadow (mode MirrorRequest via un sidecar) pendant 1 semaine. Aucune divergence détectée → bascule progressive 10% / 50% / 100% sur 3 jours. Régulation ACPR : journal de migration (qui, quand, quoi) fourni en pièce de conformité.
Scénario 3 — Migration legacy e-commerce 5.4 → 7.x grand saut (Showroomprivé legacy)
Une boutique e-commerce historique tourne sur un Symfony 5.4 patché à la main (15 bundles internes, 800 000 LOC). L'équipe planifie un saut 5.4 → 7.1 en une fois, étalé sur 9 mois pour absorber les 2 sauts majeurs. Approche : (1) gel des features sur 2 sprints, (2) Rector intensif (SYMFONY_60, puis SYMFONY_70, puis SYMFONY_71) + tests à chaque palier, (3) refonte des bundles internes en services configurés via services.yaml (les Bundle::build() legacy avec compiler passes lourdes sont rationalisés), (4) migration des Doctrine\ORM 2.x → 3.x qui supprime les EntityManager#merge(), EntityManager#refresh() avec lock — refactor ~120 endroits, (5) Webpack Encore → AssetMapper (gain : zéro Node.js en prod). Les tests fonctionnels passent de PHPUnit 9 → 11 (data providers en attribute, etc.). Livraison en blue-green Kubernetes avec rollback automatique si erreur 5xx > 0.5%. Bilan : 9 mois, 4 développeurs full-time, gain de vélocité features ×1.4 derrière (DX moderne, autowire complet, attributs natifs).
🛠️ Exemple end-to-end
Cas : pipeline Rector configuré pour migration progressive 5.4 → 6.4 → 7.1, avec tests guarding.
<?php
// rector.php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Doctrine\Set\DoctrineSetList;
use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
use Rector\Symfony\Set\SymfonyLevelSetList;
use Rector\Symfony\Set\SymfonySetList;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
__DIR__ . '/tests',
])
->withSkip([
__DIR__ . '/src/Legacy/*',
])
->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml')
->withPhpSets(php83: true)
->withSets([
LevelSetList::UP_TO_PHP_83,
SymfonyLevelSetList::UP_TO_SYMFONY_71,
SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES,
SymfonySetList::CONFIGS_ARRAY_TO_OBJECT,
DoctrineSetList::DOCTRINE_CODE_QUALITY,
DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,
PHPUnitSetList::PHPUNIT_110,
SetList::CODE_QUALITY,
SetList::TYPE_DECLARATION,
])
->withImportNames(removeUnusedImports: true)
->withParallel();# .github/workflows/upgrade-guard.yml
name: upgrade-guard
on: [pull_request]
jobs:
deprecation-check:
runs-on: ubuntu-24.04
strategy:
matrix:
step: ['5.4', '6.4', '7.1']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: 8.3 }
- run: composer install --no-progress
- name: Check deprecations
env: { SYMFONY_DEPRECATIONS_HELPER: 'max[self]=0&max[direct]=0' }
run: vendor/bin/phpunit --display-deprecations
rector-dry-run:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: 8.3 }
- run: composer install --no-progress
- run: vendor/bin/rector process --dry-run --no-progress-bar<?php
// migrations/Upgrade5to6/RemoveLegacyAnnotations.php
declare(strict_types=1);
namespace App\Migrations\Upgrade5to6;
use App\Entity\Order;
use Doctrine\ORM\Mapping as ORM;
// AVANT (Symfony 5.4 / annotations Doctrine)
// /**
// * @ORM\Entity
// * @ORM\Table(name="orders")
// */
// class Order { /** @ORM\Id @ORM\Column(type="uuid") */ public string $id; }
// APRÈS (Symfony 6.4+ / attributs natifs)
#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class OrderAfter
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
public string $id;
}<?php
// src/Security/Upgrade/LegacyUserAuthenticator.php (post 6 → 7)
declare(strict_types=1);
namespace App\Security\Upgrade;
use App\Repository\UserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
// Symfony 7 supprime Guard\AbstractGuardAuthenticator → Authenticator natif obligatoire.
// Note : signatures TYPÉES en 7.x (l'ancien code Guard était en mixed) — le typehint
// strict de onAuthenticationSuccess/Failure est exigé par AuthenticatorInterface.
final class LegacyUserAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly UserRepository $userRepository,
) {
}
public function supports(Request $request): ?bool
{
return $request->headers->has('X-Legacy-Token');
}
public function authenticate(Request $request): Passport
{
$token = $request->headers->get('X-Legacy-Token')
?? throw new AuthenticationException('No token');
// UserBadge avec un loader explicite : pas de magie, on contrôle la requête DB.
return new SelfValidatingPassport(
new UserBadge($token, fn (string $id) => $this->userRepository->findByApiToken($id)),
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// null = on laisse la requête continuer vers le controller (cas API stateless).
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => 'auth_failed'], Response::HTTP_UNAUTHORIZED);
}
}# Pipeline opérationnel
composer require symfony/flex --update-with-all-dependencies
composer recipes:update
SYMFONY_DEPRECATIONS_HELPER=max[self]=0 vendor/bin/phpunit
vendor/bin/rector process --dry-run
vendor/bin/phpstan analyse --level=8
bin/console debug:container --deprecationsCouverture : config Rector multi-niveaux + CI guarding deprecations + diff annotations→attributs + nouveau Authenticator Security. Le pipeline tourne sur chaque PR pour empêcher la régression.
🏋️ Exercices
Progression : on démarre par un repo « volontairement en retard » (Symfony 5.4 + PHP 7.4, annotations partout, un MessageHandlerInterface, un GuardAuthenticator). Crée-le ou prends un vieux projet. Chaque exercice escalade.
1. Le compteur à zéro (implement)
Objectif : amener une appli Symfony 5.4 à zéro deprecation loggée, prouvé par la CI.
- Configure PHPUnit +
SYMFONY_DEPRECATIONS_HELPER=max[total]=0et fais échouer le build sur la moindreUser Deprecated. - Route le channel Monolog
deprecationvers un fichier dédié et compte les lignes. - Corrige à la main (pas Rector) au moins une deprecation Security et une deprecation Doctrine, pour comprendre ce que Rector ferait.
Indice/Solution —
framework.yamln'a rien à voir ; c'estmonolog.yamlqui routedeprecation. Pour la CI : un job qui lancevendor/bin/phpunit --display-deprecationsavec l'env var, et ungrep -c 'User Deprecated' var/log/test.deprecations.logqui doit retourner0. Le piège : un chemin de code non testé peut cacher une deprecation — d'où l'exercice 4.
2. Pipeline Rector idempotent (implement → production-grade)
Objectif : un rector.php qui migre 5.4 → 6.4 → 7.1 en passes, et qui est idempotent (re-run = diff vide).
- Configure les
SymfonyLevelSetList,ANNOTATIONS_TO_ATTRIBUTES,LevelSetList::UP_TO_PHP_82, avecwithSymfonyContainerXmlpour que Rector connaisse tes services. - Exclus
src/Legacy/*viawithSkip. - Vérifie l'idempotence : deux
rector processd'affilée, le second ne doit produire aucun changement. Branche un test CI dessus.
Indice/Solution — l'idempotence casse souvent à cause de l'ordre des sets (un set qui défait ce qu'un autre fait) ou d'imports non résolus.
withImportNames(removeUnusedImports: true)stabilise. Le test CI :rector processpuisgit diff --exit-code(échoue si diff). Attention : ne mets pasUP_TO_PHP_83ETphp83: trueredondants — leLevelSetListinclut déjà les sets PHP.
3. Migration Security Guard → Authenticator (production-grade)
Objectif : remplacer un AbstractGuardAuthenticator par un AbstractAuthenticator natif, sans régression d'autorisation, prouvé par des tests fonctionnels.
- Écris d'abord les tests fonctionnels d'autorisation sur l'ancien Guard (un test par couple route × rôle, vérifiant 200/401/403).
- Migre vers
AbstractAuthenticator+Passport/UserBadge. BasculegetUsername()→getUserIdentifier(). - Les MÊMES tests doivent rester verts. Mesure : aucune route ne doit changer de code HTTP.
Indice/Solution — le piège classique : un
SelfValidatingPassportquand il fallait valider un mot de passe (PasswordCredentials+PasswordCredentialsbadge), ou l'inverse → trou de sécu. Mets leUserBadgeavec un loader explicite (closure vers ton repo) pour garder le contrôle de la requête. Test :KernelBrowser,loginUser(), asserts surgetStatusCode().
4. Break-then-fix : la deprecation fantôme (break → fix)
Objectif : reproduire un boot 7.0 qui explose alors que la CI 6.4 était verte, puis le rendre détectable.
- Ajoute un chemin de code utilisant une API dépréciée en 6.4 (ex.
Symfony\Component\Security\Core\Security) non couvert par les tests. - Bumpe vers 7.0 → boot KO. Constate que
--display-deprecationsen 6.4 ne l'avait pas attrapé (le chemin n'était pas exercé). - Fix : mets en place du trafic shadow (rejoue un access log de prod contre l'app 6.4 avec deprecations en erreur) qui fait surgir la deprecation AVANT le bump.
Indice/Solution — la leçon est que « tests verts » ≠ « code exercé ». Outils : un script qui lit un
access.log, rejoue les GET viaKernelBrowseroucurl, avecSYMFONY_DEPRECATIONS_HELPERen mode strict et Monologdeprecation→ exception. La vraie parade en prod : compteur de deprecations branché sur l'observabilité (cf. section Failure modes).
5. Découpler Symfony 7 et Doctrine ORM 3 (architect)
Objectif : migrer vers Symfony 7 SANS embarquer ORM 3 dans la même étape, puis migrer ORM 3 séparément.
- Vérifie quelles API ORM legacy tu utilises :
grep -rn 'merge\|->refresh(' src/. - Bumpe Symfony 7 en gardant ORM 2.x (vérifie la matrice de compat DoctrineBundle).
- Dans une PR distincte, supprime
EntityManager#merge()/refresh(lock), puis bumpe ORM 3 + DBAL 4.
Indice/Solution —
merge()n'a pas de remplaçant 1:1 : pour réattacher une entité détachée, tu fais unfind()+ copie explicite des champs, ou tu repenses le flux pour ne jamais détacher.composer why-not doctrine/orm 3.0liste les bloquants. La règle architecturale : un changement structurel = une PR = un déploiement ; empiler Symfony 7 + ORM 3 rend tout rollback impossible à diagnostiquer.
🎤 En entretien
Q : Pourquoi rester à jour sur la mineure courante rend la migration majeure « gratuite » ? Parce que la BC promise garantit qu'aucun breaking change n'arrive en mineure, et qu'une deprecation n'est retirée qu'à la majeure suivante. Donc une dernière-mineure (LTS) à zéro deprecation démarre telle quelle sur la majeure suivante : le coût est entièrement payé en amont, étalé, au lieu d'un mur à rattraper.
Q : 5.4 et 6.0 sortent le même jour et partagent le même code — quelle est la seule différence pratique ? La couche de compatibilité ascendante : 5.4 émet des User Deprecated là où 6.0 a retiré le code déprécié. Migrer 5.4→6.0 revient donc à faire taire ces deprecations. C'est aussi pour ça qu'on bump toujours vers la dernière mineure d'une majeure avant de sauter — jamais une mineure intermédiaire.
Q : La CI est 100% verte sur 6.4 mais le boot 7.0 plante. Que t'est-il arrivé et comment l'éviter ? Une deprecation sur un chemin de code non couvert par les tests : --display-deprecations ne voit que ce qui est exécuté. Parade : rejouer du trafic de prod en shadow contre la version courante avec les deprecations en erreur, et brancher un compteur de deprecations sur l'observabilité prod — la vérité est dans le trafic réel, pas dans la suite de tests.
Q : On veut Symfony 7 ET Doctrine ORM 3. Tu fais tout d'un coup ? Non. Ce sont deux migrations indépendantes qui sortent au même moment, ce qui pousse à les confondre. Je bumpe Symfony 7 avec ORM 2.x compatible d'abord (une PR), je valide en prod, puis ORM 3 dans une PR séparée. Raison : un breaking par déploiement, sinon un incident devient indébogable et le rollback ambigu.
🔗 Liens
- Roadmap Symfony : https://symfony.com/releases
- Upgrade Symfony 6.0 : https://github.com/symfony/symfony/blob/6.0/UPGRADE-6.0.md
- Upgrade Symfony 7.0 : https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md
- LTS policy : https://symfony.com/doc/current/contributing/community/releases.html
- Rector Symfony : https://github.com/rectorphp/rector-symfony
- Symfony Insight (deprecations) : https://insight.symfony.com
- Backward compatibility promise : https://symfony.com/doc/current/contributing/code/bc.html
- PHP versions supported : https://www.php.net/supported-versions.php