PHPStan niveaux 1 à 9 : autopsie d'un projet legacy

492 erreurs au niveau 1, 1 619 au niveau 9 sur un projet PHP/Laravel. Ce que les niveaux PHPStan disent de votre code legacy, palier par palier.

Dans le contrôleur de paiement d'un projet PHP/Laravel mature, ligne 473, il y a ce bloc :

} catch (\Exeption $e) {
    // ...
}

Le code part en prod comme ça depuis des années. Le catch ne capture jamais rien : la classe \Exeption n'existe pas. Toutes les exceptions de paiement sont silencieusement ignorées, glissent entre les doigts, n'arrivent jamais dans les logs.

Le projet marche pourtant. Il vend des billets tous les jours. Et l'analyse statique, dans toute son histoire, n'avait jamais tourné dessus.

PHPStan trouve ce bug en trois secondes au niveau 1. Ce qui m'a donné envie de pousser plus loin : combien d'autres surprises ce code cache-t-il, palier par palier ?

Le projet sur la table

Un projet PHP/Laravel mature, autour de 44 000 lignes, en production depuis plusieurs années. Application métier critique, du code legacy maintenu par une petite équipe interne. Si elle tombe un mardi, le business le sent le mardi.

Stack : Laravel 6, PHP 7.3. C'est ce qui rendra la suite intéressante. À l'époque où ce code a été écrit, PHP 8 n'existait pas, donc pas d'attributs, pas de readonly, pas d'enums, pas de types unionaux. Les développeurs de cette génération typaient quand ça leur paraissait utile, pas par défaut. C'est important pour comprendre ce que PHPStan trouve, et surtout ce qu'il ne trouve pas.

J'avais déjà décortiqué ce projet sous l'angle sécurité/architecture. Cette fois, on s'attaque à la qualité du code lui-même. Protocole : on remonte les neuf niveaux d'exigence de PHPStan en s'arrêtant sur cinq paliers, 1, 2, 3, 5 et 9. On regarde ce que ça donne, palier après palier.

Niveau 1 : 492 erreurs

Niveau 1, c'est l'amuse-bouche. PHPStan vérifie ce qu'aucun outil moderne ne devrait laisser passer : appels à des méthodes inexistantes, classes mal orthographiées, propriétés non déclarées, fonctions dépréciées. La doc officielle appelle ça les basic checks, l'inspection de routine avant l'inspection sérieuse.

492 erreurs sur 44 000 lignes. Un peu plus d'une erreur toutes les 90 lignes.

La pépite de l'intro tombe pile dans cette catégorie. catch (\Exeption $e) : PHPStan râle "Class \Exeption not found". Trois secondes de scan, l'erreur saute à la figure. Sur ce projet, le bloc tient intact depuis des années. Le code "marche" parce que la voie de paiement réussie s'emprunte 99 % du temps : le catch ne sert que dans le 1 % qui plante. Et quand il plante, il plante en silence.

Le reste suit la même logique. Des appels de méthodes sur des objets qui ne les déclarent pas. Des lectures de propriétés qu'aucun code n'assigne. Des fonctions natives retirées entre PHP 5.6 et 7.3, jamais remplacées dans la base de code. À chaque fois, un chemin qui pointe dans le vide.

492 méthodes ou classes appelées qui n'existent peut-être pas, sur un code qui tourne. Ces chemins ne sont jamais exécutés en prod, ou alors ils plantent silencieusement sans laisser de trace.

Niveau 2 : 884 erreurs, le saut à +80 %

On monte d'un cran. PHPStan commence à regarder les variables : sont-elles définies avant utilisation ? Toutes les branches d'un if les renseignent-elles ? Le résultat saute de 492 à 884 erreurs. +80 % d'un seul coup.

Le pattern type, vous le devinez. Une variable initialisée dans une branche if, utilisée 30 lignes plus bas sans vérifier qu'on est passé par la branche en question. Tant que la condition tombe juste à l'exécution, tout va bien. Le jour où elle tombe à côté, on appelle une variable qui n'a jamais existé, et PHP émet un Undefined variable. Ou pire : on lit une valeur héritée d'un appel précédent, dans un contexte où elle n'a aucun sens.

Multipliez ce pattern par 392 et vous avez le saut entre les deux niveaux. Dès qu'on regarde sérieusement les variables, on découvre que la moitié du code repose sur des suppositions. Du sucre flottant : ça soutient tant qu'on ne tape pas dessus.

Le projet fonctionne quand même. Pas parce que ces 392 erreurs supplémentaires sont fausses (elles sont vraies), mais parce que les conditions qui mèneraient au plantage ne se déclenchent pas en pratique. Encore.

Niveaux 3 et 5 : le plateau qui parle

On monte encore. Niveau 3, PHPStan exige de vérifier les types de retour des méthodes. Niveau 5, il s'attaque aux types d'arguments. Si tout se passait selon mon intuition, on devrait voir une nouvelle explosion. On voit l'inverse :

Niveau 2 : 884
Niveau 3 : 912    (+28, soit +3 %)
Niveau 5 : 920    (+8,  soit +1 %)

Presque rien. Trois niveaux d'augmentation de l'exigence, et 36 erreurs de plus en tout. C'est contre-intuitif. Et c'est précisément ce qu'il y a de plus parlant dans toute l'analyse.

Pourquoi le plateau ? Parce que les niveaux 3 et 5 ne vérifient pas la sûreté d'un appel : ils vérifient la cohérence entre un type déclaré et son usage réel. Le retour d'une méthode déclarée string est-il bien utilisé comme une string ? L'argument passé à une fonction qui attend User est-il bien un User ? Si la fonction ne déclare aucun type, ni dans sa signature, ni en docblock, il n'y a rien à comparer. PHPStan reste muet.

Sur ce projet, c'est exactement ce qui se passe. Laravel 6 et PHP 7.3, c'est une époque où ces réflexes n'étaient pas encore le standard. Le typage strict était facultatif, et il l'est resté. La majorité des méthodes du projet n'a aucune signature qui s'engage sur quoi que ce soit.

Le silence de PHPStan aux niveaux 3-5 n'est pas une bonne nouvelle. C'est l'aveu. Pas de violations parce que pas de promesses faites. Pas de promesses faites parce que pas de types. Pas de types parce que rien dans ce code n'a jamais formalisé ce qu'une fonction prend ou retourne.

Conséquence concrète : l'interpréteur PHP ne peut rien vérifier non plus à l'exécution. Tout est implicite. Tout repose sur la lecture humaine et la mémoire des mainteneurs, celle qui s'efface en six mois.

Le projet marche pour cette raison aussi : il n'a jamais promis grand-chose à lui-même. Aucun contrat à respecter. Aucun contrat à vérifier. Une dette à crédit illimité, invisible tant qu'on ne demande pas le relevé.

Niveau 9 : 1 619 erreurs, l'explosion finale

Niveau 9, c'est le mode strict. PHPStan exige de prouver la sûreté de chaque opération, y compris celles qu'on n'avait pas pensé à protéger. Le résultat passe de 920 à 1 619, soit +76 %. La courbe se rallume.

Ce que niveau 9 attrape : l'interdiction du type mixed, l'obligation de gérer explicitement les null, la vérification de chaque accès à une propriété ou une méthode sur une union de types. Tout ce qui était toléré jusque-là devient suspect.

Ces 699 erreurs supplémentaires ne sont pas de nouveaux bugs. Ce sont des endroits où le code n'a aucune garantie que ce qu'il manipule est ce qu'il pense manipuler. Une méthode qui reçoit un objet dont elle n'a pas vérifié le type. En pratique elle reçoit toujours le bon objet, parce que le code appelant ne varie pas. En théorie elle pourrait recevoir n'importe quoi, et rien dans le système ne l'attraperait avant le crash. Niveau 5 s'en fiche. Niveau 9 crie.

Le bon réflexe serait de dire "mais en vrai, ça marche, c'est de la paranoïa". Sauf qu'on parle d'un code qui a déjà laissé passer un catch (\Exeption) pendant des années. Ce qui semble "marcher toujours" est l'angle mort par excellence. C'est exactement là que le bug suivant va se loger.

1 619 endroits où ce code n'a aucune preuve qu'il ne va pas planter. Et il ne plante pas. Pas parce qu'il est juste : parce que personne ne lui a jamais demandé de l'être.

La courbe est l'aveu

Pour faire le bilan, on remet les chiffres ensemble :

Niveau 1 : 492       bugs probables
Niveau 2 : 884       (+80 %)  variables incertaines
Niveau 3 : 912       (+3 %)
Niveau 5 : 920       (+1 %)   <- le plateau
Niveau 9 : 1 619     (+76 %)  strict

Lue à plat, cette courbe c'est juste "beaucoup d'erreurs". Lue dans sa forme, c'est une biographie du code :

  • Le saut 1 à 2 dit que la moitié du code repose sur des variables incertaines.
  • Le plateau 3-5 dit qu'il n'y a quasi aucun type pour vérifier quoi que ce soit.
  • L'explosion 5 à 9 dit que dès qu'on exige la rigueur, tout devient incertain.

Ce que je conseille de faire avec ça ? Pas tout corriger. Le réflexe est bon (l'estomac dit "il faut nettoyer"), mais 1 619 erreurs sur 44 000 lignes, ça ne se résorbe pas en deux sprints. Sans stratégie, on abandonne à la moitié et il reste un fichier phpstan.neon qui prend la poussière.

La méthode qui marche : on attaque les 492 du niveau 1 d'abord, ce sont les vrais bugs probables. On pose une baseline qui fige le reste. Et on serre la vis sur le code neuf : aucune nouvelle erreur ne passe le filtre. Le legacy se résorbe par le bord, pas par le centre.

Et ces 1 619 erreurs ne sont pas gratuites. Chacune se paye en maintenance dérivante, en évolutions étirées, en bugs en prod. J'ai chiffré la facture annuelle de ce genre de code.

Lancer PHPStan sur votre code, c'est une heure de travail. Lire ce que la courbe dit, c'est plus long, mais c'est ce qui transforme un rapport d'outil en un diagnostic. C'est exactement le type d'analyse qui rentre dans un audit.