jobsphp

Refactoring de code legacy PHP : retours d'expérience terrain

Ingénierie Web. Refactoring de code legacy PHP : retours d'expérience terrain

Le refactoring d'une application PHP legacy n'est presque jamais un projet planifié.

Refactoring de code legacy PHP: retours d'expérience terrain

Il démarre par une CVE impossible à patcher sur une version arrivée en fin de vie, une montée de version qui révèle des incompatibilités inattendues, le départ d'un développeur senior qui détenait une partie de la connaissance métier, ou simplement des temps de réponse qui se dégradent jusqu'à rendre l'usage pénible. À ce stade, la tentation standard est de tout réécrire. C'est généralement la décision la plus risquée et la moins bien estimée.

Le problème n'est pas que les équipes seraient incapables de produire une nouvelle application. Le problème est qu'elles sous-estiment ce qu'il faut reconstruire en même temps que le code: des règles métier implicites, des exceptions accumulées au fil des tickets, des intégrations dont la documentation n'est plus à jour et des comportements devenus indispensables sans avoir jamais été formalisés.

Le piège de la réécriture complète: pourquoi le risque est sous-estimé

Les réécritures totales partent avec un avantage apparent: dette technique effacée, code moderne, choix d'architecture libre, refonte de l'interface possible au passage. Sur le papier, le projet paraît plus propre et plus facile à expliquer. En pratique, le périmètre réel s'élargit dès que l'équipe commence à comparer l'ancien comportement avec la nouvelle application.

Une estimation initiale peut alors être dépassée de manière importante, parfois par un facteur de deux ou davantage selon la complexité du domaine, la qualité de la documentation et le nombre d'intégrations à préserver. Ce n'est pas une loi mécanique et encore moins une fatalité. C'est un risque récurrent des projets qui traitent la réécriture comme un simple exercice de remplacement technologique.

La cause n'est pas uniquement technique. Les règles métier enfouies dans dix ans de correctifs, de tickets de support et de contournements ne sont pas toujours documentées: elles sont incarnées dans le code que l'on s'apprête à jeter. Une condition apparemment absurde peut gérer un cas client historique. Une colonne rarement renseignée peut être consommée par un export comptable. Une vérification placée dans un contrôleur peut compenser une contrainte absente de la base de données.

La réécriture transforme donc chaque comportement inconnu en décision à prendre. Il faut déterminer ce qui relève d'une règle, d'un bug toléré, d'une contrainte réglementaire ou d'un vestige sans utilité. Tant que cette distinction n'est pas faite, l'équipe ne réécrit pas vraiment l'application: elle formule des hypothèses sur ce que l'application devrait faire.

Conséquence classique: la nouvelle application couvre les parcours principaux, mais pas nécessairement les cas périphériques qui faisaient tenir le métier au quotidien. Les écarts apparaissent ensuite dans les imports, les exports, les traitements nocturnes, les rôles particuliers ou les intégrations avec des systèmes tiers. L'équipe se retrouve alors à maintenir deux bases de code en parallèle: l'ancienne parce que la migration est incomplète, la nouvelle parce qu'elle accumule les corrections urgentes qui n'avaient pas été anticipées.

Une réécriture totale n'est jamais un simple projet technique. C'est un projet de reconstruction de la connaissance métier, et cette connaissance ne se reconstitue pas plus vite que le code.

La décision rationnelle n'est pas d'interdire toute réécriture. Il existe des situations où l'architecture, le modèle de données ou les contraintes d'exploitation rendent le remplacement nécessaire. Mais cette option doit être traitée comme un projet de découverte métier autant que comme un projet de développement. Les scénarios critiques doivent être identifiés avant la bascule, et les hypothèses doivent être confrontées au comportement réel de l'application.

Pour la plupart des bases de code legacy, une approche progressive, construite autour de l'existant, réduit la taille du pari initial. Elle permet de transformer une inconnue globale en une série de problèmes plus petits: une route, un module, une règle métier, une intégration ou un flux de données à la fois.

Préparer le terrain: standardisation et analyse statique avant l'action

Aucun refactoring sérieux ne devrait commencer par une grande opération de nettoyage. Avant de modifier le code, il faut rendre son état observable et réduire le bruit qui empêche de distinguer les changements utiles des changements purement cosmétiques.

Trois fondations sont particulièrement importantes: une convention de formatage, une analyse statique progressivement configurée et une protection minimale contre les régressions. Elles ne rendent pas le legacy sain par magie. Elles rendent les transformations vérifiables.

Commencer par stabiliser la forme du code

PHP-CS-Fixer, configuré selon PSR-12 ou selon un standard interne assumé, doit pouvoir tourner en local et dans la CI. Son rôle n'est pas esthétique. Il évite que chaque pull request ouvre une discussion sur l'indentation, l'emplacement des accolades ou la forme des imports. Il donne aussi aux outils de transformation une base plus régulière.

Il faut toutefois éviter de mélanger le formatage avec une modification fonctionnelle massive. La première passe doit rester identifiable. Si une même PR réindente plusieurs centaines de fichiers, change des signatures et déplace en plus la logique métier, la relecture devient presque impossible. Le formatage peut être effectué dans un commit séparé ou dans une série de changements facilement isolables.

Cette discipline a un effet très concret: elle réduit les conflits dans les fichiers fréquemment touchés et permet aux développeurs de se concentrer sur les différences qui ont une portée réelle. Le code n'est pas meilleur parce qu'il est aligné. Il devient plus lisible parce que l'équipe cesse de débattre de sa mise en forme à chaque modification.

Faire parler PHPStan ou Psalm

La deuxième fondation est l'analyse statique. PHPStan et Psalm peuvent mettre en évidence les types implicites, les appels vers des méthodes inexistantes, les variables potentiellement non initialisées, les retours incohérents ou les branches impossibles à atteindre. Sur une application ancienne, la première exécution peut produire un volume d'erreurs difficile à exploiter. Ce résultat ne signifie pas que l'outil est inutilisable. Il décrit simplement l'écart entre le niveau de vérification souhaité et l'état actuel du code.

Le mauvais réflexe consiste à vouloir corriger toutes les alertes avant de livrer quoi que ce soit. Le bon consiste à établir une ligne de base, puis à empêcher l'introduction de nouvelles erreurs. Selon l'outil et la configuration retenue, cette ligne de base peut passer par un fichier d'ignorances soigneusement contrôlé, une configuration par répertoires ou une montée progressive du niveau d'analyse.

L'option reportUnmatchedIgnoredErrors=false peut être utile dans certains scénarios de transition, mais elle ne doit pas devenir une manière de cacher durablement les problèmes. Une ignorance qui ne correspond plus à une erreur réelle mérite d'être supprimée. Une erreur ignorée doit avoir une raison compréhensible et, idéalement, une trajectoire de traitement.

La progression ne se fait pas forcément fichier par fichier. Il est souvent plus efficace de travailler par frontière fonctionnelle: un module de facturation, un ensemble de commandes, un sous-domaine exposé par une API. Cette organisation permet d'associer l'amélioration des types à la compréhension du métier, plutôt que de traiter les alertes comme une liste abstraite.

Protéger les comportements qui comptent

La couverture de tests est la troisième fondation, mais le pourcentage affiché par l'outil ne suffit pas à mesurer la sécurité d'un refactoring. Une suite peut couvrir beaucoup de lignes tout en ignorant les transitions importantes, les permissions, les erreurs d'intégration ou les traitements asynchrones.

PHPUnit ou Pest peuvent servir de base, à condition de commencer par les comportements qui présentent le plus de risque:

  • les règles de calcul et de décision qui ont un impact financier ou contractuel;
  • les commandes d'écriture qui modifient plusieurs agrégats ou plusieurs tables;
  • les contrôles d'accès et les changements de rôle;
  • les imports et exports qui alimentent d'autres systèmes;
  • les traitements planifiés, souvent moins visibles dans les tests fonctionnels;
  • les formats de réponse attendus par les clients externes.

Dans une base dépourvue de tests, il peut être nécessaire de commencer par des tests de caractérisation. Ils ne disent pas si le comportement actuel est élégant ou même souhaitable. Ils enregistrent ce que fait l'application avant le changement. Cette photographie imparfaite est souvent préférable à une réécriture fondée sur des suppositions.

La règle utile est simple: plus une transformation est automatique, plus il faut savoir ce qui permet de détecter une régression. Un outil qui modifie rapidement le code sans filet de vérification ne fait que déplacer le risque.

Automatisation du nettoyage: le rôle clé de Rector et PHP-CS-Fixer

Une fois la chaîne de contrôle en place, le nettoyage automatique devient exploitable. Rector est particulièrement adapté aux transformations qui doivent comprendre la structure du code. Il travaille sur l'arbre syntaxique abstrait, et non sur une simple recherche de chaînes de caractères. Cette différence est essentielle dès que la modification dépend du contexte: type d'une expression, signature d'une méthode, portée d'une variable ou structure d'une classe.

Rector peut accompagner le remplacement de constructions anciennes, la modernisation de certaines syntaxes, l'ajout de déclarations de type lorsque les informations disponibles le permettent ou l'adaptation progressive à une version plus récente de PHP. Il ne déduit cependant pas la règle métier cachée derrière une méthode et ne sait pas, à lui seul, si une modification est compatible avec une bibliothèque tierce ou un comportement attendu par un client.

Easy Coding Standard, ou ECS, peut compléter l'outillage en regroupant la configuration de PHP-CS-Fixer et de PHP_CodeSniffer. L'intérêt n'est pas d'empiler les outils. Il est de rendre reproductible la séquence de contrôle et de fournir à l'équipe une commande commune, exécutée de la même manière sur les postes et dans la CI.

Il faut se méfier des estimations globales de la part de code modernisable automatiquement. Cette part dépend fortement de la version de départ, de la qualité des annotations, de l'usage de fonctionnalités dynamiques, du nombre de dépendances et de la présence de code généré. Sur une base bien structurée, l'automatisation peut absorber une part substantielle des transformations syntaxiques. Sur une base très dynamique, elle s'arrête rapidement devant les décisions qui demandent une compréhension du contexte.

OutilCible principaleType d'interventionUsage typique
RectorModernisation du codeTransformation de l'arbre syntaxiqueAdaptation à une version PHP, remplacement de constructions anciennes
PHP-CS-FixerFormatage et conventionsRéécriture de la forme du codePSR-12, règles internes, vérification en CI
PHPStanDétection de problèmes de types et de cohérenceAnalyse statiqueTypes de paramètres et de retours, flux de données, erreurs potentielles
PHPUnit ou PestVérification des comportementsTests automatisésRégression, caractérisation, tests unitaires et fonctionnels

Le passage des outils doit rester lisible. Une séquence raisonnable consiste à appliquer le formatage, exécuter les transformations Rector sur un périmètre maîtrisé, lancer l'analyse statique, puis exécuter les tests. Dans certains projets, le formatage intervient après Rector pour normaliser le résultat; l'important est surtout que l'ordre retenu soit documenté et stable.

Ce que Rector ne doit pas décider

Certaines transformations demandent une revue humaine, même si elles sont techniquement automatisables:

  • l'ajout d'un type qui modifie le comportement face à null;
  • le remplacement d'une comparaison souple dans un code qui reçoit des données externes;
  • la modification d'une signature publique consommée par un autre service;
  • la suppression d'une propriété dynamique dont l'existence est attendue par un mécanisme de sérialisation;
  • l'adaptation d'un appel de bibliothèque dont la compatibilité n'est pas couverte par les tests.

Les règles Rector doivent donc être activées par familles et par périmètres. Une migration globale peut être tentante, mais elle rend le diagnostic plus difficile en cas d'échec. Il est préférable de lancer une règle sur un module, d'examiner les changements, de corriger les exceptions et d'étendre ensuite le périmètre.

Le nettoyage du code PHP complexe gagne aussi à être séparé de la migration de version. Les deux opérations peuvent se soutenir, mais elles ne poursuivent pas exactement le même objectif. La migration cherche la compatibilité avec une version cible. Le refactoring cherche une structure plus compréhensible et plus évolutive. Les mélanger dans une seule livraison rend les régressions plus difficiles à attribuer.

Le pattern Strangler Fig: isoler et remplacer les briques legacy

Le pattern Strangler Fig répond à une question très concrète: comment remplacer progressivement un monolithe sans attendre que l'intégralité du nouveau système soit terminée? Le principe consiste à placer une frontière devant l'application, puis à rediriger progressivement certains flux vers de nouvelles implémentations. Le code historique reste en fonctionnement tant qu'une capacité n'a pas été remplacée et vérifiée.

En PHP, cette frontière peut s'appuyer sur un reverse proxy, un routeur applicatif ou une couche d'API. Symfony Routing, le routeur de Laravel ou FastRoute peuvent participer à cette organisation, mais aucun composant ne rend le découpage automatique. Il faut décider ce qui constitue une unité de migration: une route, un cas d'usage, un module, une commande ou une capacité métier.

Le découpage par écran est parfois pratique, mais il peut masquer une logique partagée qui reste dans l'ancien système. Le découpage par cas d'usage oblige davantage à clarifier les dépendances. Dans les deux cas, la frontière doit être explicite: qui appelle quoi, où sont validées les données, qui possède l'écriture et quel système fait foi pendant la coexistence?

La base de données est généralement le point le plus délicat. Faire lire et écrire deux applications dans les mêmes tables peut accélérer le démarrage, mais cela prolonge le couplage. Les anciennes conventions de nommage, les colonnes surchargées et les effets de bord des triggers deviennent alors des contrats de fait. À l'inverse, séparer immédiatement les bases peut imposer une synchronisation complexe avant même que le premier module ne soit stabilisé.

La coexistence doit donc être traitée comme une phase avec ses propres règles:

  • Frontières nettes: les appels entre l'ancien et le nouveau code passent par des interfaces, des DTO ou des messages identifiables, plutôt que par des accès directs à des détails internes.
  • Propriété des données: pour chaque table ou agrégat, l'équipe sait quel système est autorisé à écrire et comment les autres obtiennent une représentation cohérente.
  • Migrations réversibles: les changements de schéma prévoient la coexistence des anciennes et des nouvelles versions lorsque cela est nécessaire.
  • Bascule contrôlée: les feature flags et le routage permettent de limiter l'exposition et de revenir au chemin précédent si les indicateurs se dégradent.
  • Tests de contrat: les réponses, événements et erreurs attendus sont vérifiés entre les implémentations, sans exiger que leur code interne soit identique.
  • Observabilité: logs corrélés, métriques de latence, taux d'erreur et traces permettent de comparer les deux chemins sur des cas réels.
Le Strangler Fig ne rend pas la migration rapide par définition. Il rend surtout le risque plus localisable, à condition que les frontières, les données et les mécanismes de retour arrière soient réellement maîtrisés.

Le pattern peut échouer. Une dépendance cachée entre un nouveau service et une classe legacy, une écriture concurrente non documentée ou un feature flag impossible à désactiver suffit à recréer un monolithe distribué. La migration doit donc régulièrement supprimer les anciennes dépendances, et pas seulement ajouter de nouvelles couches devant elles.

Sur un monolithe PHP comportant de nombreuses routes, le découpage peut s'étaler sur une longue période. Ce délai n'est pas une preuve d'échec. Il devient problématique lorsque l'équipe ne définit aucun périmètre terminé, aucun critère de sortie et aucune date à laquelle une partie de l'ancien code sera effectivement supprimée. Un strangler qui ne supprime jamais rien n'est qu'une nouvelle façade posée sur la dette existante.

Gestion de la dette technique face aux échéances de sécurité PHP

L'urgence ne dicte pas la méthode, mais elle dicte la cadence. Le cycle de support de PHP impose des dates publiques que tout projet de modernisation doit intégrer à sa roadmap. Ces dates ne remplacent pas la vérification de l'environnement réel: les distributions Linux, les hébergeurs et les contrats de support étendu peuvent appliquer des calendriers différents. Elles constituent néanmoins le calendrier de référence du projet PHP.

Le tableau suivant reprend les dates publiées pour le support des branches PHP. Le statut d'une version dépend du moment où l'on consulte le calendrier: une branche peut passer du support actif au support de sécurité sans changer de numéro de version.

Version PHPStatut sur la période de supportFin du support officiel
8.0Fin de vie26 novembre 2023
8.1Fin de vie après le cycle de sécurité31 décembre 2025
8.2Correctifs de sécurité uniquement31 décembre 2026
8.3Correctifs de sécurité uniquement31 décembre 2027
8.4Support actif, puis support de sécurité31 décembre 2028
8.5Support actif, puis support de sécurité31 décembre 2029

Pour PHP 8.4, le support actif court jusqu'au 31 décembre 2026, avant une période de correctifs de sécurité jusqu'au 31 décembre 2028. PHP 8.3 n'est donc plus dans sa phase de support actif: il reste maintenu pour les correctifs de sécurité jusqu'au 31 décembre 2027. PHP 8.2 suit la même logique avec une fin de support fixée au 31 décembre 2026.

Cette distinction a des conséquences pratiques. Une équipe peut décider de cibler une branche qui reçoit encore des correctifs de sécurité, mais elle doit savoir ce qu'elle achète: de la stabilité à court terme, pas nécessairement le bénéfice d'une branche en support actif. À l'inverse, viser la version la plus récente demande de vérifier la compatibilité du framework, des extensions PHP, des bibliothèques et des outils d'exploitation.

Si la base tourne sur une version arrivée en fin de vie, la migration devient un sujet de sécurité avant d'être un sujet de confort architectural. Rector peut aider à traiter certaines incompatibilités et dépréciations, mais il ne remplace ni la lecture des notes de migration ni la validation des extensions natives. Les problèmes les plus coûteux ne se trouvent pas toujours dans le code applicatif: ils peuvent concerner une extension abandonnée, un pilote de base de données, une version de libxml, une configuration d'exécution ou une dépendance Composer non compatible.

Le travail de migration gagne à être découpé en étapes vérifiables:

1. Inventorier la cible et les contraintes: version de PHP, framework, extensions, dépendances Composer, workers, tâches planifiées et environnements de déploiement.

2. Rendre visibles les dépréciations: activer les journaux adaptés et traiter les avertissements avant la bascule finale, sans les masquer dans la configuration.

3. Mettre à jour les dépendances par groupes cohérents: une bibliothèque d'infrastructure peut imposer une évolution du framework ou d'une extension.

4. Tester les chemins rarement utilisés: commandes d'administration, imports, exports, webhooks, scripts de maintenance et traitements nocturnes.

5. Déployer avec un retour arrière réaliste: sauvegardes, compatibilité du schéma et capacité à remettre l'ancienne version sans perdre les écritures.

La dette technique n'est pas une abstraction. Dans le cas d'une version PHP non supportée, elle devient une exposition datée. Le calendrier officiel permet de rendre cette exposition lisible auprès des responsables produit et de la direction. Il évite aussi de présenter la migration comme une opération esthétique alors qu'elle conditionne la capacité à recevoir des correctifs de sécurité.

Le refactoring qui passe en production

Le refactoring PHP legacy n'est ni une réécriture intégrale ni une procrastination éternelle. C'est un travail progressif, outillé et séquencé, qui transforme une base difficile à modifier en système un peu plus prévisible à chaque passage.

Le premier objectif n'est pas de rendre toute l'application moderne en une seule fois. Il est d'obtenir un périmètre dans lequel l'équipe peut changer le code sans perdre la connaissance du comportement existant. Cela passe par une convention de formatage, une ligne de base d'analyse statique, des tests de caractérisation lorsque c'est nécessaire et des transformations automatiques limitées à ce que les outils savent réellement traiter.

Les pratiques qui tiennent le mieux dans la durée sont celles qui laissent des traces vérifiables:

  • standardisation via PHP-CS-Fixer dans le poste de développement et la CI;
  • analyse statique avec PHPStan ou Psalm, sur un périmètre qui s'élargit progressivement;
  • modernisation automatisée via Rector, avec revue humaine des changements sensibles;
  • tests ciblés sur les règles métier, les intégrations et les flux à risque;
  • isolation des nouveaux modules par des contrats et un routage contrôlable;
  • suivi explicite de la propriété des données pendant la coexistence;
  • migration PHP calée sur le calendrier officiel et sur les contraintes réelles de l'écosystème.

À l'inverse, plusieurs décisions rendent le chantier plus fragile:

  • lancer une réécriture totale avant d'avoir identifié les comportements critiques;
  • mélanger formatage, migration de version et modification métier dans une même livraison illisible;
  • considérer le taux de couverture comme une preuve suffisante de sécurité;
  • augmenter le niveau de PHPStan en ignorant les alertes sans plan de réduction;
  • laisser le nouveau code appeler directement des classes internes du legacy;
  • faire cohabiter deux systèmes qui écrivent les mêmes données sans règle de propriété;
  • repousser la migration jusqu'à la fin de vie de la version en production.

Il n'existe pas de méthode qui élimine le risque. Une stratégie de refactoring de code legacy PHP sérieuse consiste à le rendre visible, à le limiter et à le traiter par zones. Le gain ne se mesure pas seulement au nombre de classes modernisées. Il se voit dans la capacité à livrer un changement sans rouvrir les mêmes incidents, à comprendre pourquoi une règle existe et à supprimer progressivement les parties du système qui n'ont plus de raison d'être.

Sur du PHP legacy, la troisième voie existe donc bel et bien: ni grand soir architectural, ni immobilisme. C'est une succession de petites décisions techniques reliées par une stratégie de refactoring PHP progressif, des outils adaptés et une échéance de support que l'équipe accepte enfin de regarder en face.

Questions fréquentes

Pourquoi la réécriture totale d'une application PHP est-elle déconseillée ?
Elle est risquée car elle nécessite de reconstruire des règles métier implicites, des intégrations non documentées et des comportements historiques qui ne sont souvent formalisés que dans le code existant.
Comment stabiliser une base de code legacy avant de la refactoriser ?
Il est recommandé d'instaurer une convention de formatage, de mettre en place une analyse statique avec PHPStan ou Psalm, et de protéger les comportements critiques par des tests de caractérisation.
Quel est l'intérêt du pattern Strangler Fig pour le refactoring ?
Ce pattern permet de remplacer progressivement un monolithe en isolant des fonctionnalités derrière une nouvelle frontière, ce qui évite d'attendre la fin du projet pour mettre en production les nouvelles briques.
Comment gérer les erreurs lors de l'introduction de l'analyse statique ?
Il ne faut pas chercher à tout corriger immédiatement, mais plutôt établir une ligne de base pour empêcher l'introduction de nouvelles erreurs tout en traitant progressivement les alertes existantes.
Quelles sont les limites de l'automatisation avec Rector ?
Rector excelle dans les transformations syntaxiques et structurelles, mais il ne peut pas déduire les règles métier cachées ni garantir la compatibilité avec des comportements attendus par des systèmes tiers.