Pourquoi une évolution interne de Symfony peut casser du code Drupal custom
Drupal s'appuie sur Symfony pour son injecteur de dépendances, son système d'événements et son noyau HTTP. C'est une force (un cœur mutualisé, maintenu en dehors du projet Drupal) et une contrainte : quand Symfony fait évoluer un détail interne, Drupal l'absorbe, parfois avec une dépréciation et un délai, parfois avec un changement direct à la version majeure suivante.
Le problème vient des raccourcis pris par du code custom : un service Symfony non public, une classe marquée comme interne, ou une signature de méthode qui n'était pas censée bouger. L'API publique de Drupal, elle, reste stable. Ce qui fonctionnait hier plante à l'exécution, ou pire, se dégrade silencieusement après une montée de version.
Reste à savoir où ces points d'extension bougent vraiment (services non publics, classes @internal, events) et où ils sont faits pour durer (les Symfony Contracts), pour que votre équipe backend sache où accrocher son code custom.
Le service Symfony non public : la première limite à connaître
Le changement de visibilité par défaut (Symfony 5 face à Drupal 9/10)
Depuis Symfony 5.2, les services du conteneur sont devenus entièrement privés par défaut, et la méthode setPrivate() a été dépréciée (ticket Drupal 3187074). Le cœur de Drupal a dû forcer ses propres services à public quand rien n'était précisé à la construction du conteneur, pour ne pas casser l'écosystème existant.
Conséquence directe pour le code custom : la documentation officielle de Drupal est explicite : dans les conteneurs Symfony modernes (donc dans Drupal 10 et 11), un service est traité comme privé par défaut et doit être injecté comme dépendance plutôt que récupéré directement (documentation Drupal.org sur les services). Un service ne devient public que pour une raison explicite et documentée : un pont de compatibilité ascendante, par exemple.
Il y a aussi un piège plus ancien, propre aux services créés par code plutôt que déclarés en .services.yml : le passage de Symfony 4.4 (sous Drupal 9) à Symfony 5.4 et plus (sous Drupal 10) a rendu obligatoire la déclaration explicite de chaque service créé programmatiquement comme public ou privé, sous peine d'avertissement de dépréciation dès Drupal 9, puis d'erreur à partir de Drupal 10.0.0 (change record Drupal.org).
Bonnes pratiques : injection de dépendances plutôt que \Drupal::service()
La règle pratique tient en une phrase : votre code custom reçoit ses dépendances par le constructeur du service, déclarées dans son .services.yml, plutôt que d'appeler \Drupal::service() au milieu d'une méthode. \Drupal::service() reste utile dans certains contextes (un hook, une classe statique ancienne), mais dès qu'une classe est elle-même un service, l'injection de dépendances la protège d'un service qui deviendrait privé ou renommé.
Concrètement : un module custom qui récupère \Drupal::service('mon_module.un_truc') à plusieurs endroits du code prend un risque qu'un module qui l'injecte une fois, au constructeur, n'a pas.
Les classes et interfaces marquées @internal : hors de la promesse de compatibilité
Symfony documente sa promesse de compatibilité ascendante, et elle a une exception explicite : les classes, propriétés, méthodes, interfaces et traits marqués @internal en sont exclus. La documentation officielle est directe sur ce point : une interface @internal ne doit être ni utilisée ni implémentée par du code tiers (documentation Symfony sur la compatibilité ascendante).
Pour une équipe backend Drupal, ça se traduit par une vérification simple avant d'étendre ou d'implémenter quoi que ce soit venant de Symfony : la présence de l'annotation @internal dans le bloc de documentation de la classe ou de l'interface. Si elle est là, ce point d'extension peut changer de signature, d'implémentation, ou disparaître à n'importe quelle version mineure, sans avertissement préalable. Ce n'est pas un bug de Symfony si ça casse, c'est le contrat qui a toujours dit que ça pouvait casser.
Les Symfony Contracts : la bonne porte d'extension
symfony/contracts : des interfaces découplées des implémentations
Face aux services privés et aux classes internes, Symfony propose un point d'extension pensé pour durer : le composant Symfony Contracts. Sa documentation officielle décrit le principe : en typant sur les interfaces fournies plutôt que sur les classes concrètes des composants, le code custom peut réutiliser n'importe quelle implémentation conforme au contrat (documentation du composant Contracts).
En pratique : si votre code custom a besoin d'un service qui peut être réinitialisé entre deux requêtes, il type sur Symfony\Contracts\Service\ResetInterface, pas sur l'implémentation concrète du conteneur. L'implémentation peut changer de version en version, le contrat reste stable.
Exemple concret : une dépréciation qui n'aboutit pas toujours à la migration promise
Un exemple utile pour nuancer l'idée qu'une dépréciation se résout toujours par la migration recommandée. Un ticket core Drupal, fermé en « fixed », documente que Drupal\Component\DependencyInjection\Container implémentait Symfony\Component\DependencyInjection\ResettableContainerInterface, dépréciée depuis Symfony 4.2 au profit de Symfony\Contracts\Service\ResetInterface (ticket Drupal 3029197).
La résolution retenue à l'époque n'a pourtant pas été de migrer vers ResetInterface : elle n'existait pas encore dans Symfony 3.4, alors toujours supporté par Drupal 8. Drupal a simplement cessé d'implémenter l'interface dépréciée, en gardant le comportement existant. La leçon pour votre code custom : une dépréciation Symfony signale un changement à venir, elle ne garantit pas que l'alternative recommandée est déjà disponible dans la version de Symfony que vous ciblez. Vérifiez la résolution réelle avant de copier le même chemin.
Les events : un terrain miné pour le code custom
La dépréciation de Symfony\Component\EventDispatcher\Event
Symfony\Component\EventDispatcher\Event est dépréciée depuis Symfony 4. Drupal a introduit une classe-pont, Drupal\Component\EventDispatcher\Event, à utiliser à la place (change record Drupal 3159012). C'est un exemple ancien (Drupal 9.1, l'époque de Symfony 5), mais il illustre bien le mécanisme : un module custom qui étendait directement la classe Symfony s'est retrouvé à devoir changer son use pour la classe Drupal.
Le changement d'ordre des arguments de dispatch()
Le même changement a modifié l'ordre des arguments de dispatch() : l'événement passe en premier argument, le nom de l'événement en second (change record Drupal 3159012). Drupal 9.1.x déclenchait un avertissement de dépréciation quand l'ancien ordre d'arguments était utilisé : la couche de compatibilité faisait encore passer l'appel, ce qui laisse le code fonctionner tout en accumulant une dette à traiter avant la majeure suivante.
ContainerAwareEventDispatcher déprécié depuis Drupal 10.3
Plus récent : Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher est déprécié depuis Drupal 10.3.0. Tout code qui l'étendait pour remplacer le service cœur doit être refactoré pour étendre Symfony\Component\EventDispatcher\EventDispatcher à la place (change record Drupal 3376090). Si votre équipe maintient un dispatcher d'événements custom plus ancien que Drupal 10.3, c'est le premier point à vérifier avant la prochaine montée de version.
Drupal 11 et Symfony 7 : ce que ça change concrètement
Le composer.json du paquet runtime drupal/core, à l'étiquette 11.0.0, fixe les composants Symfony du cœur (console, dependency-injection, event-dispatcher, http-foundation, http-kernel, routing, serializer, validator, yaml…) sur la contrainte ^7.1 (composer.json runtime de Drupal 11.0.0). Drupal 11.0.0 tourne donc sur la branche Symfony 7, avec un plancher à 7.1 (contrainte ^7.1, soit >= 7.1 et < 8.0), contre Symfony 6.x pour Drupal 10. La version mineure réellement installée dépend du composer.lock du projet.
Un saut de version majeure de Symfony est le moment où les changements de compatibilité sont autorisés sur l'API publique ; les classes @internal et les services privés, eux, peuvent changer à n'importe quelle version mineure, sans attendre une majeure. Pour les versions 11.1 et suivantes, lisez la contrainte dans le composer.json du tag concerné plutôt que de l'extrapoler depuis la 11.0.0.
Comment anticiper plutôt que subir
Trois réflexes limitent le risque sans ralentir le développement.
Avant d'écrire une ligne qui touche à Symfony, vérifiez la visibilité du service ciblé dans son fichier .services.yml et la présence d'une annotation @internal sur la classe ou l'interface visée. Les deux se lisent en quelques secondes et évitent le point d'extension qui casse à la prochaine montée de version.
Préférez systématiquement l'injection de dépendances à l'appel direct d'un service, et typez sur une interface Symfony Contracts quand elle existe pour le besoin plutôt que sur l'implémentation concrète. Ce choix s'écrit en autant de lignes aujourd'hui, et il évite la reprise de code quand le service visé change de visibilité ou de nom, par exemple le passage des services créés par code à une déclaration explicite public/privé entre Drupal 9 et 10.
Avant une montée de version majeure de Drupal (donc de Symfony), une vérification s'ajoute : relisez les change records qui touchent à l'injection de dépendances et aux événements, les deux familles qui concentrent la plupart des ruptures documentées dans cet article. Les outils d'analyse statique orientés Drupal aident à repérer ces usages dans une base de code existante, sans qu'on puisse ici recommander une version précise sans nouvelle vérification.
Nous détaillons la mécanique des dépréciations Symfony dans les modules custom, et la méthode pour geler puis faire évoluer ces dépendances de façon contrôlée, dans deux articles complémentaires : Symfony et Drupal : anticiper les dépréciations dans vos modules custom et Dépendances Symfony/Drupal : gel, mises à jour contrôlées et rollback.
Pour aller plus loin
Le socle Symfony de Drupal est une dépendance à lire avant de s'y accrocher. Un service privé, une classe @internal, un ordre d'arguments qui change : chacun de ces cas a été documenté par Drupal ou Symfony avant de devenir un problème pour qui l'a lu à temps.
Si votre base custom accumule des années d'appels directs à des services internes et que vous préparez une montée de version, nous pouvons l'auditer avec vous et prioriser les correctifs qui comptent avant la mise à jour. C'est aussi ce que nous détaillons dans Réduire la dette Drupal en s'appuyant sur les composants Symfony utiles.
Demander un diagnostic