Skip to content

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, rector avec set symfony-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 de User Deprecated.
  • Une deprecation N'EST RETIRÉE qu'à la majeure suivante (6.x → 7.0). Donc une 6.4 100% sans deprecation = une 7.0 qui 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.

AxeMineure (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 à jourcomposer update1–2 jours1–2 semaines
Effort si en retardlogs noyéssemaines/moismois
Rector aide ?PartielOui (sets SYMFONY_xx)Oui (LevelSetList)
Le vrai risquebundles tiers non maintenusextensions C, libs natives

Compatibilité PHP

SymfonyPHP minPHP max testéEOL Symfony
4.4 LTS7.1.38.0Nov 2023 (mort)
5.07.2.57.4Juil 2020
5.4 LTS7.2.58.xNov 2025 (fin support) — 2029 (sécu pro)
6.08.0.28.1Jan 2023
6.28.18.2Mai 2023
6.38.18.2Janv 2024
6.4 LTS8.18.3+Nov 2027 — 2032 (sécu pro)
7.08.28.3Juil 2024
7.18.28.3Janv 2025
7.28.28.4Juil 2025
7.38.28.4Janv 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 via doctrine/annotations, mais 7.0 retire le support.
  • Symfony\Component\Routing\Annotation\Route → use ce namespace (pas Sensio\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). Utilise MessageBusInterface injecté.
  • AbstractController::get('foo') (service locator legacy) → injecte le service via constructeur.
  • getDoctrine() supprimé en 6.0. Injecte EntityManagerInterface ou ManagerRegistry.
  • $this->getUser() retourne UserInterface|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 : Authenticator remplace GuardAuthenticator. Active via enable_authenticator_manager: true (par défaut en 6.0).
  • GuardAuthenticationHandler, AbstractGuardAuthenticatorsupprimés.
  • Custom authenticators : extends Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator.
  • User\UserInterface::getUsername()getUserIdentifier().
  • EquatableInterface, PasswordAuthenticatedUserInterface ajoutés pour des cas précis.
  • Voters : signature inchangée, mais Voter::supports doit retourner bool.

Messenger

  • MessageHandlerInterface déprécié en 5.4, supprimé en 7.0. Remplace par l'attribut #[AsMessageHandler] sur la classe (ou méthode).
  • Worker ne broadcast plus certains events legacy.
  • Stratégie retry : explicite via transport config (retry_strategy.max_retries).
php
// 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\Annotations côté metadata, drops Doctrine\Common\Cache).
  • Doctrine\ORM\EntityManager → typehint EntityManagerInterface.
  • @ORM\Entity annotation → #[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.
php
#[AsEventListener(event: KernelEvents::REQUEST, priority: 10)]
final class LocaleListener {
    public function __invoke(RequestEvent $event): void { /* ... */ }
}

Autowiring & DI

  • Removal of Symfony\Bundle\FrameworkBundle\Controller\AbstractController::container access (was protected mixed) → use constructor injection.
  • Service IDs sensibles à la casse (étaient case-insensitive).
  • Symfony\Component\DependencyInjection\ContainerInterface::has() retourne bool strict.

Session

  • framework.session.storage_idframework.session.storage_factory_id (factory pattern, requis pour avoir une session par requête en mode worker).
  • SessionInterface injectable via $session = $request->getSession(). Ne plus injecter SessionInterface directement (deprecated).

HttpClient & Mailer

  • Mailer : MailerInterface stable. DSN avec scheme dédié (smtp+oauth, gmail, ses+api).
  • HttpClient : ScopingHttpClient pour multi-config par host.

Twig / Form

  • Twig 3.x requis (Twig 2.x EOL).
  • Form types : ChoiceType accepte des callables pour choice_label. Quelques options renommées.

Liste de deprecations clés à fixer

bash
# 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/annotations n'est plus une dépendance du squelette Symfony 7. Si tu utilises encore @Route, @ORM\Entity, etc., installe doctrine/annotations manuellement 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) remplace Symfony\Component\Security\Core\Security (deprecated).
  • getUser(), isGranted(), getToken() toujours là mais via le nouveau service.
php
// 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_transport recommandé partout.
  • Suppression de MessageHandlerInterface et MessageSubscriberInterface → uniquement #[AsMessageHandler].

Serializer

  • getSupportedTypes() requis pour bénéficier du cache (sinon perte de perf). Custom normalizers doivent l'implémenter.
  • ObjectNormalizer stricter sur les types.

Validator

  • Certaines contraintes legacy (Length::$strict etc.) supprimées. Préfère les contraintes modernes.

HttpKernel / Routing

  • RouterInterface::generate() lance RouteNotFoundException (déjà 6.x) — mais types stricts.
  • Request::getSession() lance SessionNotFoundException si pas de session configurée (avant : retournait null parfois).

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\Cache au 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.3

Step-by-step playbook :

  1. Verrouille les versions dans composer.json ("symfony/*": "5.4.*") pour éviter des bumps surprise pendant le chantier.

  2. Bumpe PHP 7.4 → 8.1 d'abord (Symfony 5.4 supporte 8.x). Fix les warnings PHP 8 ($undefined strict, Stringable, mixed returns). Lance phpstan niveau 6+.

  3. Élimine TOUTES les deprecations Symfony 5.4 :

    • Active SYMFONY_DEPRECATIONS_HELPER=max[total]=0 en CI.
    • Lance la suite de tests : tout doit passer sans User Deprecated log.
    • Rector : vendor/bin/rector process src --set symfony-code-quality --set symfony54.
  4. 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.
  5. 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.
  6. Bumpe PHP 8.1 → 8.2 (requis pour Symfony 7).

  7. Bump Symfony 6.4 → 7.0 :

    • Fix breakings : Security namespace, retrait de annotations Doctrine (si pas déjà migré), #[AsMessageHandler] partout.
  8. 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 modeSymptômeCause racineParade
Bundle tiers non maintenucomposer require refuse de résoudreLe bundle plafonne symfony/*: ^6.4Fork + patch, ou composer.json replace, ou virer le bundle. Audite-les AVANT le bump (composer why-not symfony/framework-bundle 7.0).
Deprecation noyéeTout est vert mais 7.0 explose au bootDeprecation 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 strictServiceNotFoundException au démarrage, pas en devAutowiring case-sensitive / service private qui était publicCompile 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êtesstorage_id (singleton) au lieu de storage_factory_idMigre vers le factory pattern (cf. section Session). Obligatoire en runtime persistant.
Doctrine ORM 2 → 3merge()/refresh(lock) supprimés, erreurs metadataORM 3 drop l'API legacy en même temps que Symfony 7Découple les deux migrations. Ne bumpe pas ORM 3 dans la même PR que Symfony 7.
PHP 8.x semanticsTypeError runtime, null interditmixed implicite, @ qui ne masque plus les erreurs fatalesPHPStan niveau 8+ AVANT le bump PHP, pas après.
Recipes divergentesConfig qui ne fait plus rien, defaults changésconfig/packages/*.yaml figé à l'ancien squelettecomposer 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 deprecation vers 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_60
    • Rector\Symfony\Set\SymfonySetList::SYMFONY_64
    • Rector\Symfony\Set\SymfonySetList::SYMFONY_70
    • Rector\Symfony\Set\SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES
    • Rector\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]=0 fait é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.md est la source de vérité. À lire avant chaque bump majeur.
bash
# 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.x3.x qui supprime les EntityManager#merge(), EntityManager#refresh() avec lock — refactor ~120 endroits, (5) Webpack EncoreAssetMapper (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
<?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();
yaml
# .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
<?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
<?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);
    }
}
bash
# 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 --deprecations

Couverture : 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]=0 et fais échouer le build sur la moindre User Deprecated.
  • Route le channel Monolog deprecation vers 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/Solutionframework.yaml n'a rien à voir ; c'est monolog.yaml qui route deprecation. Pour la CI : un job qui lance vendor/bin/phpunit --display-deprecations avec l'env var, et un grep -c 'User Deprecated' var/log/test.deprecations.log qui doit retourner 0. 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, avec withSymfonyContainerXml pour que Rector connaisse tes services.
  • Exclus src/Legacy/* via withSkip.
  • Vérifie l'idempotence : deux rector process d'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 process puis git diff --exit-code (échoue si diff). Attention : ne mets pas UP_TO_PHP_83 ET php83: true redondants — le LevelSetList inclut 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. Bascule getUsername()getUserIdentifier().
  • Les MÊMES tests doivent rester verts. Mesure : aucune route ne doit changer de code HTTP.

Indice/Solution — le piège classique : un SelfValidatingPassport quand il fallait valider un mot de passe (PasswordCredentials + PasswordCredentials badge), ou l'inverse → trou de sécu. Mets le UserBadge avec un loader explicite (closure vers ton repo) pour garder le contrôle de la requête. Test : KernelBrowser, loginUser(), asserts sur getStatusCode().

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-deprecations en 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 via KernelBrowser ou curl, avec SYMFONY_DEPRECATIONS_HELPER en mode strict et Monolog deprecation → 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/Solutionmerge() n'a pas de remplaçant 1:1 : pour réattacher une entité détachée, tu fais un find() + copie explicite des champs, ou tu repenses le flux pour ne jamais détacher. composer why-not doctrine/orm 3.0 liste 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

Bibliothèque tech perso — Achref