Refactoriser un projet PHP legacy en 2026 : méthode, outils et pièges à éviter

Pour refactoriser un projet PHP legacy en 2026, commencez par un diagnostic automatisé avec PHPStan monté progressivement jusqu'au niveau 9 et une mesure de la complexité cyclomatique, puis appliquez Rector pour les migrations syntaxiques avant d'introduire des tests de caractérisation et le pattern Strangler Fig sur les modules les plus critiques.

Pour refactoriser un projet PHP legacy en 2026, commencez par un diagnostic automatisé avec PHPStan monté progressivement jusqu’au niveau 9 et une mesure de la complexité cyclomatique, puis appliquez Rector pour les migrations syntaxiques avant d’introduire des tests de caractérisation et le pattern Strangler Fig sur les modules les plus critiques.

Écran d'ordinateur portable affichant du code PHP en cours d'analyse statique dans un IDE, avec une tasse de café sur un bureau de développeur
Le diagnostic par analyse statique (PHPStan) est la première étape avant tout refactoring d'un projet PHP legacy.

Diagnostic initial de la dette technique

L’analyse commence par l’exécution de PHPStan sur le code existant en augmentant les niveaux de règles une à une. On configure d’abord phpstan.neon avec level: 5, puis on corrige les erreurs de type avant de passer au niveau 7 qui impose des vérifications sur les propriétés nullables. Un exemple concret consiste à analyser un service legacy :

// avant
class OrderService
{
    private $repository;
    public function __construct($repository) { $this->repository = $repository; }
}

Après passage au niveau 9, le constructeur doit explicitement typer RepositoryInterface et lever les exceptions possibles. Pour la complexité cyclomatique, on utilise PHP Metrics en ligne de commande sur les classes les plus volumineuses. Les modules dépassant 15 points de complexité sont isolés dans un rapport CSV avant toute intervention. Cette étape s’intègre naturellement dans un workflow Cursor ou Claude Code pour générer les premières règles de configuration.

Le résultat de PHPStan ne doit toutefois pas être traité comme une liste d’erreurs à corriger mécaniquement. Certaines alertes révèlent des conventions historiques, par exemple une méthode qui retourne parfois false, parfois un tableau et parfois null. Dans ce cas, le premier travail consiste à documenter le contrat réel de la méthode, puis à le stabiliser avec un type de retour cohérent ou un objet de résultat. Corriger uniquement l’annotation sans traiter les branches d’exécution masquées déplacerait le risque au lieu de le réduire.

Il est également utile de séparer les erreurs issues du code applicatif de celles générées par les bibliothèques anciennes ou par les fichiers de bootstrap. Des fichiers de baseline PHPStan peuvent temporairement contenir les alertes héritées, à condition de ne pas les utiliser pour masquer les nouvelles erreurs. L’objectif est simple : aucune dette supplémentaire ne doit entrer dans le dépôt pendant que l’équipe résorbe l’existant. Les exclusions doivent rester minimales, commentées et régulièrement réexaminées.

Automatisation des migrations avec Rector

Rector applique les règles de migration de syntaxe et d’API dépréciées sans intervention manuelle sur chaque fichier. On installe rector/rector et on crée rector.php avec les sets Symfony et PHP 8.3 :

use Rector\Config\RectorConfig;
use Rector\Symfony\Set\SymfonySetList;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->sets([SymfonySetList::SYMFONY_64]);
    $rectorConfig->rule(\Rector\Php83\Rector\Class_\ReadOnlyClassRector::class);
};

On exécute ensuite rector process src –dry-run pour visualiser les changements, puis sans l’option dry-run sur les bundles les moins critiques. Rector gère également les mises à jour de dépendances Composer obsolètes en remplaçant les appels à des méthodes supprimées dans Doctrine ou Symfony, un chantier à recouper avec l’audit de sécurité des dépendances Composer pour ne pas migrer une bibliothèque tout en ignorant une vulnérabilité connue sur une autre. La documentation officielle de Rector détaille les règles disponibles pour chaque version de PHP et de Symfony.

L’automatisation doit être découpée en commits lisibles. Une migration de syntaxe, un renommage de méthode et une évolution fonctionnelle ne devraient pas être mélangés dans la même livraison. Cette séparation facilite la revue de code, limite les conflits Git et permet de revenir rapidement en arrière si une règle Rector produit un effet inattendu dans une extension spécifique du projet.

Certaines règles exigent aussi une validation métier après transformation. Le passage à des classes readonly, par exemple, est approprié pour des objets de transfert de données réellement immuables, mais peut être inadapté à des entités Doctrine ou à des services dont les propriétés sont initialisées tardivement. Rector accélère la modification répétitive ; il ne remplace ni la connaissance du cycle de vie des objets ni l’exécution des tests de caractérisation.

Développeuse analysant un rapport d'erreurs d'analyse statique sur plusieurs écrans dans un bureau moderne
La revue des rapports PHPStan et Rector demande un jugement métier, pas seulement l'application mécanique des correctifs suggérés.

Mise en place des tests de caractérisation

Avant de modifier le code legacy, on écrit des tests de caractérisation qui capturent le comportement actuel sans le juger. Avec PHPUnit, on crée une suite spécifique :

class LegacyOrderTest extends TestCase
{
    public function testExistingOrderCalculation(): void
    {
        $service = new OrderService($this->getLegacyRepository());
        $result = $service->calculateTotal(42);
        self::assertEquals(1250, $result); // valeur observée en production
    }
}

Ces tests s’exécutent sur la base de données de recette et enregistrent les sorties des méthodes critiques. On utilise ensuite le composant Panther pour les parcours E2E qui touchent les formulaires legacy, en s’appuyant sur la même approche que le guide des tests E2E Symfony avec Playwright et Panther. La documentation Symfony sur les tests fonctionnels explique comment isoler les requêtes HTTP sans toucher aux contrôleurs existants. Une liste des priorités de couverture se présente ainsi :

  • Méthodes appelées plus de 500 fois par jour
  • Endpoints exposés publiquement
  • Calculs financiers ou de stock

Un test de caractérisation peut volontairement figer un comportement qui paraît discutable : arrondi comptable inattendu, format de date non standard, code d’erreur historique ou ordre précis des lignes dans un export. Ce n’est pas une validation de la qualité fonctionnelle. C’est une protection contre une modification silencieuse du contrat utilisé par un client, un partenaire ou un job interne. Lorsqu’un comportement doit réellement évoluer, le changement doit être porté par une décision métier explicite et par un nouveau test décrivant la règle cible.

Les dépendances externes doivent être contrôlées dans cette suite. Les appels à une API de paiement, un SMTP ou un service tiers peuvent être remplacés par des doubles de test, tandis que les requêtes SQL critiques sont testées sur un jeu de données représentatif. Pour les exports CSV et les fichiers générés, comparer le contenu structuré est souvent plus robuste qu’une comparaison binaire complète : on vérifie les colonnes, les montants, l’encodage et les cas limites sans rendre les tests fragiles face à une date de génération variable.

Application du pattern Strangler Fig

Le pattern Strangler Fig consiste à créer une nouvelle couche Symfony ou Laravel autour du monolithe sans le remplacer d’un coup. On ajoute un routeur qui redirige progressivement les appels :

// config/routes/legacy.yaml
legacy_order:
    path: /order/{id}
    controller: App\Controller\Strangler\OrderProxyController

Le contrôleur proxy vérifie un feature flag et délègue soit au code legacy soit au nouveau service refactorisé. On commence par les endpoints les moins risqués, comme la consultation de catalogue, avant de migrer la facturation. Ce découpage progressif évite les big-bang et permet de mesurer la latence à chaque étape. Le guide de migration Symfony 6 vers 7 fournit les bonnes pratiques pour maintenir la compatibilité des services pendant la transition.

La bascule doit préserver les éléments invisibles du contrat HTTP : codes de statut, redirections, en-têtes, messages d’erreur, droits d’accès et paramètres de pagination. Une nouvelle implémentation peut être techniquement plus propre tout en rompant un intégrateur qui dépend d’un champ historique ou d’une erreur 422 précise. Comparer les réponses legacy et nouvelles sur un échantillon de requêtes permet de détecter ces écarts avant l’activation complète du feature flag.

Pour les opérations sensibles, il est préférable d’activer le nouveau chemin par groupes cohérents : comptes internes, utilisateurs pilotes, puis une fraction limitée du trafic. Les logs doivent indiquer clairement quelle branche a traité la demande. Cette traçabilité simplifie le rollback et évite d’attribuer à tort une anomalie au nouveau code alors qu’elle provient d’une donnée historique ou d’un service externe.

Pièges classiques à éviter

Plusieurs erreurs récurrentes compromettent le refactoring. La première consiste à supprimer du code sans tests de caractérisation, ce qui fait apparaître des régressions en production. La deuxième est de vouloir tout réécrire dans un nouveau framework en une seule fois, augmentant les risques de rupture de contrat. Une troisième erreur fréquente reste l’ignorance des dépendances Composer obsolètes qui bloquent ensuite les mises à jour de sécurité. Enfin, ignorer les effets de bord sur les jobs en file d’attente ou les exports nocturnes crée des incidents nocturnes difficiles à diagnostiquer. Une liste numérotée des contrôles avant chaque commit aide à les prévenir :

  1. Exécution complète de la suite de tests de caractérisation
  2. Vérification des dépendances avec composer outdated
  3. Analyse PHPStan sur les fichiers modifiés
  4. Revue des feature flags actifs

Un autre piège consiste à modifier simultanément le schéma de base de données et toute la logique applicative qui l’utilise. Les migrations doivent être compatibles avec l’ancienne et la nouvelle version du code pendant la période de déploiement. Ajouter une colonne, écrire dans les deux formats si nécessaire, basculer les lectures puis supprimer l’ancien champ dans une étape ultérieure réduit fortement le risque d’indisponibilité.

Il faut aussi éviter les améliorations de style sans objectif vérifiable dans les zones les plus fragiles. Renommer massivement des variables ou réorganiser l’ensemble des namespaces peut rendre les revues difficiles et masquer une régression fonctionnelle. Les changements mécaniques doivent être isolés, automatisés lorsque possible et validés avant d’entamer le découpage architectural.

Priorisation basée sur le risque métier réel

La priorisation ne suit pas la propriété du code mais l’impact sur le chiffre d’affaires et la continuité de service. On classe les modules selon une matrice simple :

Module Fréquence appels Impact financier Complexité Priorité
Calcul de TVA 12000/jour Élevé 18 1
Export CSV clients 800/jour Moyen 9 2
Historique commandes 4500/jour Faible 22 3

Les zones à fort risque métier reçoivent d’abord les tests et Rector. Les modules internes à faible trafic sont laissés en l’état tant que leur dette ne bloque pas les mises à jour de sécurité. Cette approche s’aligne avec les recommandations de la documentation Symfony sur l’observabilité et l’interview DevOps Symfony qui insiste sur la mesure du risque plutôt que sur la volumétrie du code. Des liens vers des ressources complémentaires comme le guide complet Symfony 2026 et l’article sur l’IA dans le workflow Symfony permettent d’approfondir l’intégration d’outils comme Claude Code pour accélérer l’analyse des zones prioritaires.

La matrice peut être complétée par la réversibilité du changement et par le niveau de connaissance détenu dans l’équipe. Un module peu volumineux mais connu d’une seule personne représente un risque opérationnel réel. De même, un traitement lancé une fois par nuit peut être prioritaire s’il bloque la facturation, les expéditions ou la clôture comptable. Les journaux d’erreur, les tickets support et les alertes de supervision apportent souvent une vision plus concrète de la dette que le seul nombre de lignes de code.

Tableau blanc de bureau de développement avec un schéma d'architecture logicielle dessiné à la main
Cartographier les dépendances entre modules avant de prioriser le refactoring évite de sous-estimer le risque opérationnel d'une zone peu documentée.

Un refactoring PHP legacy croise aussi souvent des enjeux de sécurité applicative plus larges : dépendances obsolètes, bibliothèques abandonnées ou composants exposés qui n’ont jamais été audités. Le magazine i-actu.fr documente régulièrement ces sujets de cybersécurité et de cloud pour les équipes techniques, une lecture complémentaire utile une fois le diagnostic PHPStan terminé.

Le suivi continu avec PHPStan et Rector intégré dans les pipelines GitLab ou GitHub Actions garantit que la dette technique ne se reconstitue pas. Chaque itération de Strangler Fig réduit la surface legacy tout en maintenant la stabilité métier. Les seuils d’analyse peuvent être durcis progressivement : interdire les nouveaux mixed, exiger des retours typés sur les services modifiés et refuser les dépendances abandonnées dans les nouvelles fonctionnalités. Cette discipline transforme le refactoring en activité continue plutôt qu’en projet exceptionnel difficile à financer.

Questions fréquentes

Par où commencer pour refactoriser un projet PHP legacy sans risque ?
Par un diagnostic automatisé, pas par la réécriture. Exécutez PHPStan en commençant au niveau 5 puis en montant progressivement, mesurez la complexité cyclomatique des classes les plus volumineuses avec PHP Metrics, et écrivez des tests de caractérisation sur les méthodes critiques avant de toucher au code. Cette étape de diagnostic évite de refactoriser à l'aveugle.
Qu'est-ce qu'un test de caractérisation et pourquoi en écrire avant de refactoriser ?
Un test de caractérisation capture le comportement actuel du code, même discutable (arrondi inattendu, format de date non standard), sans juger s'il est correct. Il protège contre une modification silencieuse d'un contrat utilisé par un client ou un job interne. Une fois le comportement figé par le test, le refactoring peut se faire en confiance.
Rector peut-il remplacer une revue de code manuelle ?
Non. Rector automatise les transformations mécaniques (migration de syntaxe, API dépréciées) via des rule sets prédéfinis, mais certaines règles, comme le passage à des classes readonly, exigent une validation métier après transformation. Rector accélère la modification répétitive, il ne remplace ni la connaissance du cycle de vie des objets ni les tests.
Comment prioriser les modules à refactoriser dans un gros projet ?
Pas selon la propriété du code ou son ancienneté, mais selon l'impact réel sur le chiffre d'affaires et la continuité de service : fréquence d'appel, impact financier, complexité. Un module peu volumineux mais connu d'une seule personne dans l'équipe représente un risque opérationnel réel qui doit aussi entrer dans la priorisation.
Le pattern Strangler Fig est-il adapté à toutes les migrations PHP legacy ?
Il convient particulièrement quand une réécriture complète en une fois serait trop risquée. Une nouvelle couche Symfony ou Laravel est ajoutée autour du monolithe, avec un routeur proxy qui bascule progressivement les appels vers le nouveau code. Le point de vigilance est de préserver les éléments invisibles du contrat HTTP : codes de statut, en-têtes, messages d'erreur précis.