Quelqu'un a enfin fait le travail que nous autres remettons sans cesse à plus tard : compiler l'extension VLD contre PHP 8.3.6 et dumper les opcodes compilés de la même fonction deux fois, une fois avec declare(strict_types=1) en tête de fichier et une fois sans. Les deux listings sont identiques. Même RECV qui récupère l'argument, même VERIFY_RETURN_TYPE à la sortie, structure identique du premier opcode au dernier. La strictesse n'atteint jamais le bytecode. Elle vit sous forme de flag sur le fichier compilé, et le runtime consulte ce flag à l'intérieur d'une vérification de type que le moteur effectue de toute façon.

vld: greet(int $age): string
Function greet:
line #* E I O op fetch ext return operands
 2 0 E > RECV !0
 3 1 ROPE_INIT 3 ~2 'You+are+'
 2 ROPE_ADD 1 ~2 ~2, !0
 3 ROPE_END 2 ~1 ~2, '+years+old'
 4 VERIFY_RETURN_TYPE ~1
 5 > RETURN ~1

Cette seule mesure enterre discrètement l'objection de la performance. La comparaison entre le type déclaré et la valeur réelle a lieu à chaque appel typé, strict ou pas ; le flag décide seulement si une incompatibilité lève une TypeError ou déclenche une tentative de conversion. Lever une exception est, s'il faut trancher, la branche la moins coûteuse. Le coût n'a donc jamais été la vraie question. La vraie question, c'est la topologie, parce que le flag appartient au fichier d'où part l'appel, et c'est précisément ce détail qui fait mal aux équipes. Voici ma position : adoption totale ou rien. Une base de code à moitié stricte est pire qu'une base totalement laxiste, parce qu'au moins la laxiste se trompe de manière prévisible.

Déroulons ce que « par fichier » veut dire concrètement. Pour les arguments, c'est le fichier de l'appelant qui décide : si un fichier A déclare strict_types et appelle un takesInt() défini dans un fichier B laxiste, cet appel est strict. Pour les valeurs de retour, c'est l'inverse, la vérification s'exécute là où vit l'instruction return, donc une fonction dans un fichier laxiste peut tranquillement convertir la chaîne "5" en l'int qu'elle a promis, quelle que soit la strictesse de son appelant. Vu de l'intérieur, c'est cohérent, la vérification obéit au fichier dans lequel elle s'exécute. Vu de l'extérieur, cela signifie qu'exactement la même fonction accepte "25" quand elle est appelée depuis admin/report.php et lève une exception quand elle est appelée depuis cron/rebuild.php. Essaie d'expliquer ça à la personne d'astreinte quand ces deux chemins de code divergent à 2 heures du matin.

Et le mode d'échec du laxisme n'est pas bruyant, il est arithmétique. Passe une chaîne de date comme "2024-01-01" à un paramètre déclaré int et PHP garde les premiers chiffres et jette le reste, si bien que ta fonction calcule désormais avec 2024. Deux dates de la même année s'effondrent sur le même nombre, un décompte de jours ressort à zéro, un rapport s'affiche vide, et rien ne te prévient. L'article qui a mené l'expérience VLD a retracé exactement ce schéma dans un service de reporting financier, et la facture de débogage s'est élevée à deux jours. Avec le flag activé, la même erreur devient une TypeError avec une stack trace qui pointe vers l'appelant, corrigée en moins d'une minute.

Le contre-argument honnête mérite son temps d'antenne. Du code legacy peut dépendre de la coercition volontairement : les données de formulaire arrivent en chaînes, les vieux chargeurs de config te donnent "1" là où un bool est attendu, et un basculement brutal fait remonter d'un coup toutes les incompatibilités tolérées, en production si tu es négligent. Il y a aussi de vraies limites à ce que le flag t'apporte. Les fonctions internes l'ignorent complètement, strlen(123) fait sa coercition dans le fichier le plus strict que tu possèdes. Les types de classes ont toujours été stricts, pareil pour les tableaux et les callables. Et un auteur de bibliothèque ne peut pas imposer la strictesse à ses consommateurs, puisque c'est le fichier de l'appelant qui l'emporte. Donc non, strict_types n'est pas un champ de force. Il gouverne les déclarations scalaires sur tes propres fonctions, rien de plus.

Je milite quand même pour l'adoption universelle, précisément parce que le périmètre est aussi étroit et le coût aussi proche de zéro. Des types déclarés qui sont réellement appliqués, c'est le sol sur lequel PHPStan et Psalm se tiennent ; sans application, tes signatures ne sont que des commentaires optimistes. La migration est ennuyeuse mais faisable. Les nouveaux fichiers reçoivent la ligne declare sans discussion. Les vieux fichiers la reçoivent un par un avec la suite de tests qui tourne. Les TypeError qui remontent se corrigent à la vraie frontière, c'est-à-dire un cast explicite (int) là où les données de requête entrent dans le système, pas des casts saupoudrés sur chaque site d'appel. Si tu te retrouves à caster chez chaque appelant, c'est que la signature ment de toute façon et qu'elle réclame probablement un type union à la place.

Une chose que le dump d'opcodes m'a laissée en tête : si la strictesse n'est qu'une branche différente au runtime, alors l'opt-in par fichier était un choix de conception du langage, pas une nécessité technique. PHP aurait pu la rendre globale au moteur et a choisi de ne pas le faire, sans doute pour que vingt ans de code existant continuent de tourner, ce qui est défendable. Mais ce choix fait peser le fardeau de la cohérence sur nous. Alors dis-moi comment tu le portes. Ta base de code est-elle à 100 pour cent en strict_types, et sinon, quel fichier ne recevra jamais la ligne declare, et quelle est la raison honnête ? Je soupçonne que les réponses en disent plus long sur nos systèmes que n'importe quel dump de bytecode.