jobsphp

Migration PHP 8 : les leçons d'une transition réussie

Écosystème PHP. Migration PHP 8 : les leçons d'une transition réussie

0 == "foo": vrai sous les anciennes règles de comparaison de PHP, faux à partir de PHP 8.

Migration PHP 8: les leçons d'une transition réussie

Cette ligne résume mieux une migration PHP 7 vers 8 que la plupart des tableaux de compatibilité. Le binaire démarre. Composer résout les paquets. Le déploiement passe. Puis une branche métier fondée sur une coercition implicite retourne un autre résultat. Pas de syntaxe invalide. Pas de fatal error au démarrage. Juste une application qui ne calcule plus exactement la même chose.

Le retour d’expérience d’une migration projet PHP 7 vers 8 utile ne porte donc pas sur le nombre de fichiers modifiés. Il porte sur les hypothèses supprimées par le moteur: comparaisons lâches, appels dynamiques, signatures imprécises, avertissements jusqu’ici ignorés, dépendances installées contre une plateforme fictive. PHP 8 ne transforme pas automatiquement une base legacy en application moderne. Il force le code à révéler ses zones non déterministes.

PHP 7.4 est sorti du support le 28 novembre 2022. PHP 8.0 a lui-même atteint sa fin de vie le 26 novembre 2023. Migrer aujourd’hui depuis PHP 7 en visant 8.0 revient à déplacer une dette de sécurité d’un répertoire à un autre. La cible doit être choisie en fonction du composer.lock, des extensions installées, de Symfony ou Laravel, de PHPUnit et de la matrice de support réelle du serveur.

Au-delà de la syntaxe: PHP 8 casse surtout les implicites

Le piège le plus coûteux des erreurs courantes de migration PHP 8 est de ne chercher que les API supprimées. Elles sont faciles à repérer: un grep, un analyseur statique, un test d’intégration et le processus tombe au bon endroit. Les ruptures de comportement sont plus dangereuses. Elles produisent parfois un résultat valide, mais faux.

PHP 8 a durci plusieurs zones historiques du langage. C’est un mouvement sain pour le typage et la prévisibilité d’exécution. Mais une base PHP 7 ancienne a souvent construit ses raccourcis sur la permissivité du runtime.

Les cas qui doivent être isolés avant toute bascule sont les suivants:

  • Les comparaisons non strictes entre nombres et chaînes non numériques. 0 == "foo" et 0 == "" ne retournent plus true. Toute logique de validation, de filtrage, de recherche en tableau ou de contrôle d’état utilisant == mérite un test de régression ciblé.
  • Les appels à des fonctions supprimées: create_function(), each(), __autoload(), les casts (real) et (unset), ainsi que l’accès aux offsets avec des accolades. Ces usages ne sont pas négociables: il faut les réécrire.
  • count() sur une valeur non comptable. Là où un warning pouvait être absorbé par la production, PHP 8 remonte un TypeError. Un champ nullable provenant d’un formulaire, d’une réponse JSON ou d’une colonne SQL suffit à déclencher la rupture.
  • Le mauvais nombre d’arguments transmis à une fonction interne non variadique. Le moteur lève désormais un ArgumentCountError, au lieu de continuer avec un warning exploitable seulement dans les logs.
  • Les appels indirects via call_user_func_array(). Depuis PHP 8, les clés textuelles du tableau sont interprétées comme des noms d’arguments. Une clé inconnue, ou une clé numérique placée après une clé textuelle, conduit à une erreur fatale.

Le point commun est simple: PHP 7 pouvait exécuter du code ambigu. PHP 8 exige davantage de cohérence entre l’intention du développeur et la représentation mémoire réellement passée à la fonction.

Une migration réussie ne consiste pas à faire disparaître les fatals. Elle consiste à éliminer les comportements qui dépendaient de la tolérance du moteur.

Les comparaisons souples sont un problème de données, pas de style

Le débat == contre === a souvent été réduit à une convention de code. En migration majeure, ce n’est plus une préférence. C’est une question de domaine de valeurs.

Une donnée issue de MySQL arrive fréquemment sous forme de chaîne selon le driver, le mode d’hydratation ou la couche ORM. Une valeur lue dans $_GET est une chaîne. Un identifiant issu d’un JSON peut être un entier, une chaîne ou null. Si la logique métier mélange ces représentations sans contrat de type, une modification de règle de comparaison révèle l’incohérence.

Le mauvais correctif consiste à remplacer mécaniquement tous les == par ===. Cela casse aussi du code, mais de manière plus silencieuse: "42" === 42 est faux, même si les deux valeurs représentent le même identifiant fonctionnel.

Le bon ordre est plus rigoureux:

1. Identifier le type attendu à la frontière: requête HTTP, message de queue, payload API, ligne SQL, cache.

2. Normaliser la valeur dès son entrée dans l’application: cast explicite, DTO, validation, enum lorsque le domaine est fermé.

3. Remplacer la comparaison lâche par une comparaison strictement adaptée au type normalisé.

4. Écrire le test sur les valeurs limites: chaîne vide, null, zéro, chaîne numérique, chaîne non numérique et booléen si l’API l’accepte réellement.

Le gain n’est pas cosmétique. On réduit les branches conditionnelles dépendantes de la coercition, donc le nombre de chemins que le test fonctionnel doit couvrir. C’est une optimisation de complexité avant d’être une optimisation de performance PHP 8.

L’audit Composer: le verrouillage compte plus que la résolution locale

Composer peut donner une impression de sécurité excessive. Une commande réussie sur le poste de développement ne prouve qu’une chose: le jeu de contraintes est solvable sur cette machine, avec ce PHP, ces extensions et cette configuration.

Elle ne dit rien du conteneur de production, du pool PHP-FPM ou de l’image utilisée par la CI.

config.platform est utile pour simuler une cible pendant la résolution des dépendances. Il évite, par exemple, d’installer localement un paquet qui exige une version de PHP indisponible en production. Mais cette valeur peut aussi masquer l’écart exact que l’on cherche à contrôler. Composer résout contre une plateforme déclarée; le serveur exécute contre une plateforme réelle.

La commande composer check-platform-reqs contourne config.platform et vérifie les exigences effectives des paquets installés contre le PHP et les extensions réellement présents. Elle doit tourner dans l’environnement qui déploie: image Docker finale, runner CI représentatif ou machine cible. Pas seulement sur le laptop.

Ce que le lockfile ne sait pas dire

Le composer.lock fige les versions de paquets. Il ne valide pas les extensions chargées par PHP-FPM, la version utilisée par le CLI, les modules activés dans un pool spécifique ou une divergence entre Nginx, le conteneur applicatif et un worker asynchrone.

Une migration sérieuse sépare les niveaux de compatibilité.

CoucheQuestion techniqueÉchec typique
Langage PHPLe code passe-t-il les ruptures de PHP 8?TypeError, API supprimée, comparaison modifiée
Dépendances ComposerLes contraintes acceptent-elles la cible?Paquet bloqué sur PHP 7, conflit de versions
FrameworkLa version Symfony ou Laravel supporte-t-elle le runtime visé?Container, composants ou packages incompatibles
Outils de testPHPUnit peut-il tourner sur la cible?Suite de tests inutilisable après le bump PHP
ExtensionsLes modules requis existent-ils dans l’image finale?Échec au boot ou dans une tâche secondaire
Runtime de prodPHP-FPM, CLI, workers et cron ont-ils la même version?Déploiement vert, queue rouge

La compatibilité bibliothèques PHP 8 ne se résume donc pas à une contrainte "php": "^8.1" dans le manifeste. Les paquets privés, bundles historiques, plugins d’administration, SDK maison et extensions PECL sont souvent les derniers éléments à bloquer la montée de version. Ils ne sont pas toujours visibles dans une dépendance directe. Le graphe transitive compte.

Symfony, Laravel et les faux raccourcis

Symfony recommande une séquence nette lors d’une montée majeure: supprimer les dépréciations, puis mettre à jour les paquets via Composer. Cette discipline évite de mélanger deux classes de régression: celles introduites par le framework et celles provoquées par le runtime PHP.

Dans un projet Symfony, les dépréciations ne sont pas du bruit de log. Elles constituent une liste de mutations futures déjà annoncées. Les ignorer avant un changement de PHP revient à modifier simultanément le moteur, le framework et les contrats internes. Le diagnostic devient mauvais: une stack trace ne dit plus quel axe a cassé.

Laravel impose une autre vigilance: les arguments nommés ne font pas partie de sa garantie de rétrocompatibilité. Les noms de paramètres de ses méthodes peuvent évoluer. Ajouter des appels nommés à des API Laravel pendant la migration est donc une mauvaise optimisation locale. Le code paraît plus lisible, mais il devient dépendant d’un détail que le framework se réserve le droit de modifier.

Les seuils de version imposent aussi une planification concrète:

Outil ou frameworkSeuil PHP documentéConséquence
Laravel 9PHP 8.0.2 minimumUne cible PHP 7 est hors matrice
Laravel 10PHP 8.1 minimumPHP 8.0 est exclu
PHPUnit 10PHP 8.1 minimumUne suite PHPUnit 9 ne se modernise pas seule
PHPUnit 11PHP 8.2 minimumLa cible runtime doit suivre l’outillage
PHPUnit 12PHP 8.3 minimumLe support de la suite devient un choix de plateforme

L’erreur classique est de considérer PHPUnit comme une dépendance secondaire. C’est l’inverse pendant une migration: si la suite ne peut pas s’exécuter dans le runtime cible, l’application perd son principal instrument de mesure.

Les tests de régression doivent mesurer le comportement, pas le chargement des classes

Un test qui valide uniquement que le kernel Symfony démarre ou que les routes Laravel sont compilées ne sécurise pas la transition. Il confirme le bootstrap. Or les ruptures PHP 8 les plus coûteuses se produisent souvent après: dans l’hydratation d’un objet, une sérialisation, une règle de TVA, le calcul d’un prix, une permission, un import CSV ou un worker de queue.

Le test de migration doit viser les zones où PHP 7 absorbait les incohérences.

Construire une matrice de régression utile

Une suite de tests robuste pour ce changement de runtime couvre quatre familles de cas.

1. Les frontières de type.

Tester les valeurs qui traversent HTTP, CLI, JSON, MySQL, Redis ou une queue. Il faut vérifier les null, les chaînes vides, les zéros, les clés absentes et les tableaux incomplets. C’est là que count(), les casts implicites et les comparaisons lâches se rencontrent.

2. Les appels dynamiques.

Identifier les dispatchers, conteneurs de services, middlewares, callbacks, listeners et factories qui utilisent call_user_func_array(), la réflexion ou des tableaux associatifs pour transporter des arguments. Les clés ne sont plus de simples index: elles peuvent devenir des noms de paramètres.

3. Les parcours d’erreur.

Une exception est un changement de contrôle de flux. Si l’application convertissait les warnings en logs ou les ignorait via un handler global, elle doit maintenant capturer, corriger ou laisser remonter une exception typée. Les tests doivent vérifier le statut HTTP, le rollback transactionnel et l’état du message dans la queue après l’échec.

4. Les sorties stables.

API publiques, exports, webhooks, PDF, fichiers CSV et données sérialisées doivent être comparés sur leur contenu, pas uniquement sur l’absence d’exception. Un changement de branche causé par une comparaison stricte peut produire un JSON parfaitement valide mais sémantiquement faux.

Le benchmark migration PHP ne doit pas être inventé. Sans protocole reproductible, annoncer un pourcentage de gain n’a aucune valeur. Le protocole minimal mesure le même scénario métier, les mêmes données, le même cache et la même configuration OPcache avant et après bascule. Temps de réponse, débit, mémoire allouée, erreurs applicatives et saturation des workers doivent être observés ensemble.

Un temps moyen plus bas avec davantage de TypeError capturés dans les logs n’est pas une optimisation. C’est un test incomplet.

PHP 8 peut exécuter certaines charges différemment. Il ne corrige ni une requête MySQL coûteuse, ni un N+1 ORM, ni un cache incohérent.

Performance: ne pas confondre version du moteur et architecture saine

L’optimisation performance PHP 8 Symfony est souvent formulée comme une promesse automatique: mise à jour du runtime, activation du JIT, baisse de latence. C’est une mauvaise séquence de raisonnement.

Le coût dominant d’une requête Symfony reste fréquemment hors du moteur PHP: accès MySQL, appels HTTP, sérialisation, cache distribué, filesystem, construction de gros graphes d’objets, autoload massif ou contention sur les workers PHP-FPM. Le runtime peut améliorer certains chemins CPU-bound. Il ne rend pas un système I/O-bound soudainement rapide.

La migration est néanmoins une occasion propre de remettre les métriques au centre:

  • Mesurer le temps de boot du kernel séparément du temps métier et des accès externes.
  • Suivre le pic mémoire, pas seulement la moyenne. Une régression d’allocation peut réduire la densité de workers PHP-FPM.
  • Vérifier l’OPcache dans l’environnement réel: taille, saturation, invalidation et cohérence entre les instances.
  • Distinguer les endpoints publics, les commandes CLI et les consumers de queue. Leur profil CPU/mémoire n’est pas le même.
  • Examiner les requêtes MySQL déclenchées par les parcours couverts. Une migration PHP ne neutralise pas un index absent.
  • Contrôler le comportement sous charge avec les mêmes fixtures et le même niveau de cache chaud ou froid.

Le JIT ne doit pas être activé comme un drapeau de victoire. Il doit être évalué sur une charge qui justifie son coût et ses effets mémoire. Pour une application web conventionnelle, le bénéfice n’est pas garanti. Le compiler ne remplace pas un profilage.

Cette froideur est utile: la migration PHP 8 est une opération de compatibilité. Toute amélioration de débit ou de consommation mémoire est un résultat à mesurer, pas un argument à présupposer.

La plateforme réelle: PHP-FPM, CLI et workers doivent converger

La production ne possède pas une version de PHP. Elle en possède souvent plusieurs.

Le PHP CLI exécutant les migrations peut être différent de celui de PHP-FPM. Le worker supervisé qui traite les messages peut conserver une image plus ancienne. Une tâche cron peut appeler un binaire système hors conteneur. Un endpoint web passe par Nginx et un pool FPM avec ses propres extensions; une commande bin/console utilise un autre php.ini.

Cette fragmentation explique une classe de déploiements trompeurs: le site répond correctement, puis les jobs asynchrones échouent plusieurs heures plus tard sur une extension absente ou une incompatibilité de package.

Le contrôle doit être explicite:

  • Version du binaire PHP pour chaque mode d’exécution: FPM, CLI, worker, cron.
  • Liste des extensions requises par Composer dans l’image ou l’hôte qui exécute réellement le code.
  • Paramètres OPcache cohérents entre les pools, sans confondre cache de développement et cache de production.
  • Build reproductible du vendor/: pas de résolution non contrôlée lors du déploiement.
  • Exécution de composer check-platform-reqs dans l’artefact final ou dans un environnement strictement identique.
  • Redémarrage maîtrisé des workers persistants après livraison du nouveau code et du nouveau runtime.

Nginx n’a pas besoin d’être mis à niveau parce que PHP passe de 7 à 8. MySQL non plus, par principe. Les lier mécaniquement transforme une migration ciblée en chantier d’infrastructure. En revanche, les interactions doivent être testées: encodage de données, drivers PDO, extensions, timeouts de FastCGI, limites mémoire et comportement des processus longs.

La règle est nette: changer ce qui est requis par une incompatibilité démontrée, pas ce qui est simplement adjacent dans la stack.

Choisir une cible qui ne soit pas déjà obsolète

PHP 8 n’est pas une destination unique. C’est une famille de versions avec des fins de support différentes. La stratégie doit tenir compte du coût de la migration et de la durée de vie attendue de l’application.

PHP 8.2 ne reçoit plus que des correctifs de sécurité jusqu’au 31 décembre 2026. PHP 8.3 est couvert jusqu’au 31 décembre 2027, PHP 8.4 jusqu’au 31 décembre 2028 et PHP 8.5 jusqu’au 31 décembre 2029. Cela ne signifie pas que chaque application doit viser immédiatement la branche la plus récente. Cela signifie qu’une cible doit être justifiée par les contraintes du graphe de dépendances et par l’horizon de maintenance.

Une trajectoire rationnelle ressemble à ceci:

1. Stabiliser le code contre les ruptures PHP 8: API supprimées, coercitions, exceptions, appels dynamiques.

2. Aligner les contraintes Composer, les paquets privés et les extensions sur une version cible précise.

3. Mettre à jour le framework et PHPUnit selon leur matrice, sans masquer les dépréciations.

4. Exécuter la suite de régression sur la cible dans la CI, puis dans une préproduction identique à la production.

5. Déployer avec observabilité: erreurs typées, latence, mémoire, échecs de workers, taux de rollback.

6. Prévoir la prochaine montée de version dès maintenant, en réduisant les usages qui dépendent de détails internes des bibliothèques.

Le typage strict, les signatures explicites et la normalisation des entrées ne servent pas seulement à franchir PHP 8. Ils réduisent le coût de la prochaine version majeure. C’est le seul indicateur durable de qualité dans ce type de chantier.

Une migration depuis PHP 7 ne mérite pas le qualificatif de réussie parce qu’elle s’achève sans écran blanc. Elle est réussie si le code ne dépend plus des ambiguïtés supprimées, si Composer est validé contre la plateforme réelle, si les tests couvrent les variations de comportement et si la version cible reste maintenable après la mise en production.

Verdict: migration PHP 7 vers 8 à faire en production, mais uniquement comme une opération de compatibilité mesurée. Un simple changement d’image Docker ou de paquet système n’est pas une migration. C’est un pari.

Questions fréquentes

Pourquoi mon application fonctionne-t-elle différemment après la migration alors qu'il n'y a pas d'erreur fatale ?
PHP 8 a durci les règles de coercition implicite et de comparaison. Des expressions qui retournaient des résultats ambigus sous PHP 7 peuvent désormais produire des résultats différents ou des erreurs de type.
Comment gérer les comparaisons de type `0 == "foo"` qui ne fonctionnent plus ?
Il faut identifier les points d'entrée des données, normaliser les types (via des DTO ou des casts explicites) et remplacer les comparaisons lâches par des comparaisons strictes adaptées au type normalisé.
Pourquoi `composer install` réussit en local mais échoue en production ?
Composer peut résoudre les dépendances en fonction d'une plateforme fictive. Il est recommandé d'utiliser `composer check-platform-reqs` directement dans l'environnement de production pour vérifier la compatibilité réelle avec les extensions et la version de PHP installées.
Le JIT de PHP 8 va-t-il automatiquement accélérer mon application ?
Non, le JIT n'est pas une solution miracle. Les performances dépendent davantage de l'architecture globale, comme les accès aux bases de données ou les entrées/sorties, et le JIT doit être évalué sur des charges spécifiques pour justifier son coût mémoire.
Quels sont les risques liés aux appels indirects comme `call_user_func_array()` ?
Depuis PHP 8, les clés textuelles dans les tableaux d'arguments sont interprétées comme des noms de paramètres. Une clé inconnue ou mal placée peut provoquer une erreur fatale.