Rector a modernisé 201 fichiers de mon legacy PHP 7.3, après m'avoir planté à la première commande

Migrer un legacy PHP 7.3 avec Rector en une commande ? Sur un vrai projet, la première commande plante. Pourquoi, et la vraie méthode - cas réel, chiffres réels.

Rector, c'est l'outil qui promet de faire le sale boulot à votre place. Vous lui confiez votre vieux code PHP, il le modernise tout seul. Vous lisez la doc, vous copiez le rector.php qu'on voit partout, vous lancez vendor/bin/rector process --dry-run. Sur un projet récent, c'est magique : il vous déroule la liste de tout ce qu'il va réécrire, ligne par ligne.

Puis un jour, vous pointez exactement la même commande sur un vrai legacy. Laravel 6, PHP 7.3, en production depuis des années. Et à la place du diff magique, vous tombez sur ça :

[ERROR] Call to undefined method Rector\Config\RectorConfig::configure()

Le réflexe, c'est d'accuser le legacy, ou la version de PHP trop vieille pour l'outil. C'est exactement la mauvaise piste : le vrai coupable est ailleurs, et il est bien plus sournois.

On va voir pourquoi cette commande plante, comment la faire tourner pour de vrai, et ce qu'elle a réellement changé une fois débloquée : 201 fichiers modernisés en une passe.

Le projet sur la table

Un projet PHP/Laravel mature : environ 44 000 lignes, en production depuis des années, une application métier. Côté stack, Laravel 6 et PHP 7.3, deux versions qui ont pris de l'âge.

Le détail qui compte pour la suite, c'est pas l'âge, c'est l'immobilité. Une stack figée, des dépendances qui n'ont pas bougé depuis longtemps, un composer.lock qu'on n'ose plus toucher. L'objectif de la passe Rector était simple : moderniser la syntaxe et le typage pour préparer la migration vers PHP 8.

J'avais déjà décortiqué ce même projet en détail : le pourquoi de la migration, PHP 7 en fin de vie. Là, on s'attaque à l'outillage.

La promesse d'une commande, et le mur

La doc de Rector vend la modernisation outillée, et elle la vend bien. Vous écrivez une config courte, quelques lignes qui disent à l'outil où chercher et jusqu'où moderniser :

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/app', __DIR__ . '/routes', __DIR__ . '/database'])
    ->withPhpSets(php84: true)
    ->withPreparedSets(deadCode: true, codeQuality: true, typeDeclarations: true);

C'est exactement ce que montre la doc officielle aujourd'hui. On ne bricole rien, on suit le manuel à la lettre.

Ensuite, deux commandes : on installe l'outil, puis on lance le dry-run.

composer require rector/rector --dev
vendor/bin/rector process --dry-run

Sur le projet récent, à ce stade, le diff se déroule. Sur le legacy, à la place :

[ERROR] Call to undefined method Rector\Config\RectorConfig::configure()

Le premier réflexe, c'est de douter de sa propre config. On la relit, on la compare à la doc mot pour mot : elle est bonne. On a copié la doc officielle, et la doc officielle plante.

C'est précisément là que ça devient intéressant. Parce qu'une config correcte qui échoue, ça veut dire une chose : le problème n'est pas là où on regarde.

Le diagnostic : n'accusez pas PHP

La piste évidente, c'est la version de PHP. "Rector moderne tourne en PHP 8, le projet est en 7.3, forcément ça coince." C'est à moitié vrai, et c'est cette moitié de vérité qui fait le piège.

Oui, le php: ^7.3 du composer.json fait bien retomber Rector de sa branche 2.x vers la 1.2.10. Sauf que la 1.2.10 connaît déjà parfaitement RectorConfig::configure() : cette API existe depuis la version 1.0. Avec elle, la config tournerait, donc PHP 7.3 n'est pas la cause du crash. On creuse.

Le vrai coupable, c'est la chaîne d'analyse statique, et il faut suivre la réaction en chaîne pour le voir. larastan est la seule version compatible avec Laravel 6, et ce larastan ^1.0 plafonne phpstan dans l'ère 1.8. Or Rector 1.x, lui, réclame phpstan >= 1.12.5 : les deux exigences ne peuvent pas coexister. Le solveur de Composer, coincé entre les deux, fait la seule chose qu'il sait faire : il recule. Il descend les versions de Rector une à une jusqu'à en trouver une qui se contente de phpstan ^1.8.7.

Cette version, c'est Rector 0.14.6, datée d'octobre 2022.

Composer ne s'en cache même pas, il l'écrit dans son log :

Downgrading rector/rector (1.2.10 => 0.14.6)

Et 0.14.6, elle, n'a jamais entendu parler de RectorConfig::configure() : cette API n'arrive qu'avec Rector 1.0, en 2024. D'où le undefined method. Le message d'erreur disait vrai : la méthode n'existe pas, dans la version que Composer vous a servie sans le dire.

Le piège, ce n'est pas la version de PHP qu'on accuse d'instinct. C'est votre outil d'analyse statique qui tient Rector en otage. Et qui vous renvoie, sans un mot, une version d'il y a deux ans.

Et cet outil, c'est PHPStan, le même que j'ai poussé jusqu'au niveau 9 sur ce projet. Sauf qu'ici, enchaîné à Laravel 6 via larastan, c'est lui le grain de sable.

La vraie méthode : sortir Rector du projet

La solution tient en un renversement. On arrête de vouloir installer Rector dans le projet legacy.

Pourquoi on essaie de l'y mettre, au fond ? Par habitude. Un outil de dev, on le colle dans les require-dev, point. Sauf que les contraintes de Rector (larastan, phpstan, toute sa chaîne) n'ont rien à voir avec celles de l'application. Et surtout : Rector lit du code source. Il n'a pas besoin de partager l'arbre de dépendances de l'appli pour le réécrire, juste de lire vos fichiers.

Donc on le sort. Un environnement PHP 8 isolé (un conteneur Docker fait très bien l'affaire) où Rector 2.x s'installe sans le moindre conflit, puis on le pointe sur le code legacy via withPaths() :

docker run -v "$PWD":/app -w /app --rm composer:2 require rector/rector --dev
docker run -v "$PWD":/app -w /app --rm php:8.3-cli \
  php -d memory_limit=-1 vendor/bin/rector process --dry-run --config=rector-audit.php

Le code reste sur place ; c'est l'outil qui vient à lui, dans un environnement propre. Une fois débloqué, le reste est connu : on applique par petits lots, on teste, on commite. Rien d'original là-dedans.

Une honnêteté avant les chiffres, par contre. Ce run tourne sans le vendor de l'application : Rector voit votre code, mais pas les classes du framework. Du coup, les règles qui dépendent du typage de Laravel sous-comptent. Le chiffre que je vais donner est un plancher, pas un plafond.

Ce que Rector a vraiment fait : 201 fichiers, une passe

Une fois l'outil débloqué dans son PHP 8, un seul --dry-run de Rector 2.5.2 sur ce legacy signale 201 fichiers à modifier, 532 transformations, 54 règles distinctes déclenchées. En une passe.

Le total est parlant, mais c'est le détail qui raconte ce que Rector fait vraiment à un vieux Laravel. Le haut du classement des règles, c'est du typage et du nettoyage, presque rien d'autre :

  • des types de retour void ajoutés là où ils manquaient : 96 fois sur des méthodes, 73 sur des closures ;
  • des docblocks @return et @param supprimés, devenus inutiles une fois le type natif posé : 92 et 36 ;
  • un declare(strict_types=1) ajouté proprement en tête de fichier : 29 fois ;
  • des propriétés typées déduites du constructeur, des promotions de propriétés en constructeur : 15 et 13 ;
  • des closures transformées en arrow functions : 23.

Rien de spectaculaire pris isolément. Mis bout à bout, c'est tout un code qui passe d'un style "PHP 5 qui a survécu" à du PHP 8 typé.

Et c'est là qu'on rejoint le pilier performance, sans tricher :

Rector ne rend pas votre code plus rapide. Ce qu'il fait, c'est vous amener sur PHP 8, et c'est PHP 8 qui apporte la perf, avec le JIT et un typage natif moins coûteux à l'exécution. L'outil défriche le chemin ; le gain de perf, c'est la destination qui l'offre.

Et le rappel honnête, parce qu'il compte : 201, c'est un plancher, mesuré sans le vendor de l'appli. Sur le projet complet, avec le typage du framework visible, il y en a davantage. Un chiffre précis et honnête vaut mieux qu'un chiffre rond et gonflé.

Ce que Rector ne fera jamais

Rector est excellent, mais sur une seule chose : le mécanique. La syntaxe, les types, le dead code, tout ce qui suit une règle. Vous lui donnez la règle, il l'applique 532 fois sans broncher.

Ce qu'il ne touchera jamais, c'est la logique métier et l'architecture. Il ne sait pas pourquoi ce if imbriqué sur quatre niveaux existe, ni qu'un module de 800 lignes mériterait d'être coupé en trois. Il modernise la forme, jamais le fond.

Rector nettoie le visible : la syntaxe, les types. Ce qui reste, les bugs logiques, le typage que l'outil n'ose pas deviner, c'est PHPStan qui le traque, à condition de monter les niveaux.

Moderniser la syntaxe, c'est l'échauffement. Refondre l'architecture d'un legacy sans tout casser et sans arrêter la prod, ça porte un nom : le Strangler Fig, et c'est une autre histoire. On la racontera.

Quant au piège de la première commande, il n'était jamais dans l'outil. Il était dans l'attente : "une commande magique". Une fois qu'on sait où Rector s'arrête, on sait exactement quoi lui demander, et c'est déjà beaucoup.