Aller au contenu principal
La newsletter arrive ! Premier envoi très bientôt : laissez votre adresse pour la recevoir.

Igor PHP : voir ce que PHPStan ne voit pas en worker mode.

Igor PHP repère les fuites d'état que PHPStan ne voit pas en FrankenPHP worker mode. Premier scan de ce blog : 269 findings bruts, 3 vrais bugs.

MAJ 15 min de lecture
Sommaire · 11

PHPStan vérifie les types, la nullabilité, le shape des arrays, l'usage des classes. Il ne sait rien des cycles de vie des services au runtime. Aucune extension PHPStan ne détecte qu'un cache d'instance mute un service partagé entre deux requêtes : ce n'est pas une question de types, c'est une question d'exécution. Igor PHP comble exactement cet angle mort.

Igor est un linter écrit en Go. Il scanne une application Symfony pour pointer les services qui vont fuiter en FrankenPHP worker mode. Ce billet part d'un cas concret : le premier scan de ce blog. Ce que l'outil attrape, ce que PHPStan ne voit pas, le tri honnête des findings (269 lignes brutes pour 3 vrais bugs), et un verdict de praticien sur la CI.

Le blog tourne en FrankenPHP worker mode en prod (un billet précédent couvre le déplacement de complexité que ça impose). Igor est l'outil qui traque précisément cette classe de bug. Voici ce qu'il a trouvé.

Pour aller plus loin

La classe de bug, et pourquoi PHPStan est aveugle dessus

En worker mode, un même process PHP traite des centaines de requêtes sans redémarrer. Plus de cold start, plus de bootstrap Symfony à chaque requête. En contrepartie, un effet de bord structurel : tout service partagé qui retient un bout d'état d'une requête à l'autre devient une fuite silencieuse. Au pire, un partage involontaire de données entre utilisateurs. Symfony et Doctrine sont propres là-dessus depuis longtemps ; un service projet qui « cache » une valeur dans une propriété, beaucoup moins.

C'est une catégorie de bug que la chaîne d'outils habituelle ne voit pas. PHPStan raisonne sur le code tel qu'il est écrit : les types, les signatures, la cohérence statique. Il ne déroule pas l'exécution. Il ne sait pas qu'une Twig Extension est un singleton du container, et qu'un controller est instancié par requête. CS-Fixer normalise le style. Quant à la relecture humaine, elle a un angle mort connu sur ce point : on a tous appris PHP sur un modèle « un process par requête pardonne tout », et ce réflexe survit longtemps à la bascule en worker.

Igor attaque le problème par l'autre bout. Son mode complet lit le container Symfony pour distinguer services partagés et instances per-request, puis flag toute mutation d'état sur les premiers. C'est un travail de runtime model, pas de type-checking. D'où la complémentarité : Igor ne fait pas ce que PHPStan fait, et PHPStan ne fait pas ce qu'Igor fait.

Installer Igor

Deux voies. Soit en binaire Go autonome :

Terminal
go install github.com/igor-php/igor-php/cmd/igor@v0.8.1
# le binaire s'appelle "igor" (pas "igor-php" comme le help le suggère)

Soit en dépendance Composer dev, plus naturelle pour un projet PHP :

Terminal
composer require --dev igor-php/igor-php

Attention au chemin Composer : le paquet installe un lanceur PHP qui télécharge le binaire Go « latest » depuis GitHub à la première exécution, sans vérification de somme. La version effective ne suit donc pas votre composer.lock. Sur le blog, un petit script d'installation télécharge la release exacte du lock et vérifie son sha256 contre le checksums.txt publié ; le lanceur n'est plus jamais invoqué.

Puis on initialise une config à la racine du projet :

Terminal
igor init .

Ça crée un igor.json minimal. Les clés réellement lues : exclude (chemins à ignorer), safe_namespaces (par défaut Symfony\ et Doctrine\, raisonnable) et baseline. Les autres clés du fichier généré (console_path, env, verbose…) ne sont pas fusionnées par l'outil : passez-les en flags CLI si besoin. Le scan s'invoque ensuite avec :

Terminal
igor .                  # mode complet, nécessite que bin/console boot
igor --no-agent .       # fallback scan filesystem si console KO

Le mode complet (« Deep Audit ») lit le container Symfony pour distinguer services partagés et instances par requête : c'est le mode utile. Le --no-agent fait du scan filesystem pur sans ce contexte, nettement moins précis. On y revient dans la section quirks.

Premier scan : apprendre à lire l'outil

🧟 Igor is auditing 5059 unique shared service files for you, Master...

--- 🏁 DEEP AUDIT COMPLETE ---
Total unique service files: 5059
✅ OK (Stateless):           4766
❌ KO (Dangerous State):     272 (Project: 269, Vendor: 3)
⚠️  WARN (Review reset):      21 (Project: 21, Vendor: 0)
Time taken: 3.94s

269 findings côté projet, c'est un chiffre qui n'a pas de sens en l'état : le blog n'a même pas 200 classes dans src/. La bonne question n'est pas « combien Igor signale », c'est « combien méritent une action ». Avec un outil v0.6.0, le chiffre brut ment presque toujours, il faut le trier. Igor a des angles morts, et le mode --no-agent gonfle artificiellement le compte. Le travail commence ici.

Le tri honnête : 269 → 28 → 3

En filtrant les fichiers uniques (sur 269 lignes brutes, beaucoup étaient des duplications dues aux worktrees git, on y revient), il reste 28 fichiers projet et 3 vendor. La répartition :

Catégorie Fichiers Verdict
Twig Components (Block, Content, Admin, Seo) 22 Faux positifs structurels
Tui Widget (GifWidget) 1 Faux positif (widget = état runtime)
Validator (ValidRegexPatternValidator) 1 Faux positif (try/finally protégé)
Trait dans controller (ContentBatchActionsTrait) 1 Faux positif (controllers per-request)
Service (PostRevisionContext) 1 Faux positif (instancié par controller)
Twig Extension (RoutingExtension) 1 Vrai positif
Doctrine Subscriber (MediaCleanupSubscriber) 1 Vrai positif (risque de suppression de media croisé)
Doctrine Subscriber (PostRevisionSubscriber) 1 Vrai positif partiel (WeakMap auto-collect, mais lag GC inter-requêtes)

Le gros morceau, c'est les Twig Components. Tous les composants du block editor (Code, Heading, Image, Video…) plus les sections home (SeriesHomeSection, ReadingListHomeSection, etc.) sont flaggés parce qu'ils ont des setters qui mutent leurs propres props pendant postMount() ou applyAttrs(). Or un Twig Component n'est pas un singleton : ComponentFactory instancie une nouvelle instance à chaque render. Igor n'a pas la notion native du TwigComponentBundle ; il voit les classes déclarées dans le container et présume qu'elles sont partagées. Faux positif structurel : non actionnable, à mettre en baseline.

Le ValidRegexPatternValidator est un autre genre. Il appelle ini_set('pcre.backtrack_limit', '10000') pour évaluer un regex potentiellement vulnérable au ReDoS, puis restore l'ancienne valeur. Igor flag les deux appels. Mais le code est en try/finally propre :

PHP
$previousLimit = (string) ini_get('pcre.backtrack_limit');
ini_set('pcre.backtrack_limit', '10000');
try {
    // tests
} finally {
    ini_set('pcre.backtrack_limit', $previousLimit);
}

C'est l'idiome exact recommandé pour modifier l'état PHP global localement. Igor ne reconnaît pas le pattern try/finally autour d'ini_set. Faux positif sémantique.

Sur 28 fichiers projet, trois seulement méritent un fix. C'est ça, le signal d'un linter jeune : beaucoup de bruit, un peu de signal, et la valeur est dans ta capacité à les séparer.

Trois vrais positifs, calibrés honnêtement

Une mise au point sur la gravité avant de les détailler. Les trois bugs étaient latents : aucun n'a mordu en prod. La proposition de valeur d'Igor, c'est ça : pointer le pattern fautif au build-time, avant qu'il ne devienne un problème. Pas un sauvetage héroïque, une prévention. Et il faut le dire tel quel, sans le gonfler.

Bonne nouvelle annexe : les trois ont le même remède, ResetInterface, précisément le pattern Symfony pour les services partagés à état.

Le cache Twig sans reset : RoutingExtension

Cette Twig Extension expose ctrl_fqcn() et ctrl_route_params() pour résoudre dans les templates un shortname de controller en FQCN complet. Pour éviter de re-parser la RouteCollection à chaque appel, elle cache deux choses :

PHP
final class RoutingExtension
{
    private ?array $controllers = null;                    // liste de tous les controllers
    private array $resolvedControllers = [];               // cache shortname → FQCN
    // ...
}

Une Twig Extension est un service partagé du container : en worker mode, l'instance survit aux requêtes. Sur une journée, $resolvedControllers accumule les shortnames résolus au fil des requêtes du worker. Pour le blog, c'est borné par PageReferenceEnum, donc inoffensif en pratique. Le pattern est faux, pas le résultat. Mais le jour où un dev ajoute un cache moins borné sur le même réflexe, le leak grossit silencieusement.

Le fix tient en sept lignes. Implémenter ResetInterface, vider les deux caches :

PHP
use Symfony\Contracts\Service\ResetInterface;

final class RoutingExtension implements ResetInterface
{
    // ... code existant inchangé ...

    public function reset(): void
    {
        $this->controllers = null;
        $this->resolvedControllers = [];
    }
}

Symfony tag automatiquement les services implémentant ResetInterface avec kernel.reset (ce n'est documenté nulle part de façon centralisée, on le découvre en grep dans framework-bundle). Le kernel appelle reset() sur chaque service taggé, entre chaque requête. Pas de config service à toucher, pas de bench à courir : c'est un fix défensif, pas une optimisation. QA statique au vert : PHPStan ✅, CS-Fixer ✅. Le blog avait déjà un précédent côté SubscriberCookieJar (collecte de topics Mercure pendant le render Twig), on reste donc cohérent avec une convention déjà posée.

C'est exactement le genre de constat que PHPStan ne pouvait pas produire. Le code est correctement typé, les deux propriétés sont cohérentes, rien ne cloche statiquement. Le défaut n'apparaît qu'en projetant le code dans son cycle de vie réel : un service singleton qui traverse les requêtes.

Le risque cross-request

Subscriber Doctrine qui collecte au onFlush les mediaId retirés d'un contenu (image disparue d'un block), puis fait la collecte effective au postFlush une fois la transaction commitée. Le buffer vit dans $candidates (un array<mediaId, [table, contentId]>).

PHP
private array $candidates = [];

public function onFlush(OnFlushEventArgs $args): void
{
    // ... remplit $this->candidates ...
}

public function postFlush(): void
{
    $candidates = $this->candidates;
    $this->candidates = [];  // reset SEULEMENT si on arrive jusque là
    // ... delete media not referenced anymore ...
}

C'est, en théorie, le cas le plus conséquent du lot, et il n'est jamais survenu sur le blog. Le risque, posément : une exception fire entre onFlush et postFlush, par exemple un autre listener qui throw. $candidates reste alors rempli dans l'instance partagée du subscriber. La requête suivante démarre avec un buffer non vide, et au prochain postFlush réussi, la vérification d'usage porte sur des media de la requête précédente. Au bout de la chaîne : la suppression d'un fichier en réalité référencé ailleurs. C'est typiquement la classe de bug qui ne se manifeste qu'en worker mode : invisible en CGI/FPM, où chaque requête est un nouveau process.

Risque latent, réel, suffisant pour justifier le fix, pas un incident vécu. Le fix est identique : implements ResetInterface + reset() { $this->candidates = []; }.

Le WeakMap quasi-safe

Subscriber Doctrine qui buffer les Post à snapshoter, dans un \WeakMap<Post, bool>. Le WeakMap est intentionnel : quand le Post perd ses refs fortes (au reset de l'UnitOfWork Doctrine sur exception), la clé disparaît au prochain GC PHP. Le pattern est donc quasi-safe : pas de fuite mémoire à long terme.

Mais « quasi » fait la différence en worker mode. Entre l'exception qui crash le flush et le prochain GC PHP (déclenché par fréquence, par seuil mémoire, ou explicitement via gc_collect_cycles()), la requête suivante peut démarrer avec un WeakMap pas encore nettoyé. Le risque est plus faible que sur MediaCleanupSubscriber : un Post zombie ne déclenche pas grand-chose au postFlush suivant, au pire un snapshot dupliqué que le content_hash dédupliquera. Fix défensif quand même : implements ResetInterface + reset() { $this->pending = new \WeakMap(); }. Trois lignes pour passer de « quasi-safe » à « safe ».

Au total, la convention ResetInterface couvre maintenant RoutingExtension, MediaCleanupSubscriber, PostRevisionSubscriber et l'ancien SubscriberCookieJar. Un pattern qu'on voit, qu'on applique, qu'on oublie, et qui se généralise dans le projet.

Les quirks d'un outil

Avant de songer à la CI, six comportements à connaître.

Le exclude : ignoré en v0.6.0, précisé depuis. Mon igor.json de l'époque contenait :

JSON
{
  "exclude": [".claude/worktrees/**", "var/**", "tests/**"]
}

Et pourtant le rapport contenait 238 lignes pointant vers .claude/worktrees/<id>/src/.... La sémantique réelle, comprise depuis (et toujours valable en v0.8.x) : le matching d'exclude est un préfixe littéral, pas un glob. var/** ne matche jamais rien ; var, tests ou .claude/worktrees en préfixes nus fonctionnent très bien. Autre piège du même fichier : une clé au mauvais type (scan_vendors attend une liste, pas un booléen) fait jeter tout l'igor.json en silence, exclusions comprises. Le bon réflexe : vérifier que les exclusions s'appliquent en regardant les compteurs du scan, pas la config.

Le bundle officiel améliore la précision sans tout résoudre. Igor expose un bundle IgorPhp\IgorBundle\IgorPhpBundle qui pose un var/cache/dev/igor_service_map.json au compile du container. Ce map liste les définitions réelles du container : Igor s'en sert pour trancher partagé vs per-request. Comparé sur le même commit, à la suite :

Sans bundle (--no-agent) Avec bundle
Total services scannés 5 059 4 732
Findings projet 269 183
Findings vendor 3 30

Le bundle réduit les findings projet de 32 %. Et surtout, il identifie des vrais positifs vendor qu'on ignorait (Monolog Logger, EasyAdmin EntityPaginator, etc.), utiles à connaître pour ajuster le max_requests du worker. En revanche, les 22 Twig Components flaggés ne disparaissent pas avec le bundle activé : Igor 0.6.0 ne reconnaît pas le TwigComponentBundle comme exposant des instances par render, il les voit comme des services partagés et flag toute mutation. Limitation à signaler upstream, pas un défaut de configuration. À noter aussi : Igor a immédiatement éliminé RoutingExtension du rapport après le fix ResetInterface. La détection des fix est fiable ; c'est la baseline qui reste à constituer.

Le --no-agent n'est pas un substitut. Sans le service map, Igor scanne tous les .php du repo : il multiplie les findings par le nombre de worktrees git (cf. ci-dessus) et ajoute du bruit sur des classes non-service (factories, value objects, utilitaires).

Le service map en environnement Docker. Le compiler pass d'Igor écrit igor_service_map.json dans kernel.cache_dir, soit var/cache/dev/ côté container. Depuis, le setup du blog a tranché plus simple : le binaire s'exécute dans le container, où le map vit déjà. Plus de docker compose cp vers l'hôte ; un cache:warmup préalable suffit si le cache est froid (sans map, le mode agent sort en erreur plutôt que de dégrader en silence).

La détection échantillonne. Deux scans identiques ne rapportent pas les mêmes findings (concurrence interne). GOMAXPROCS=1 stabilise la liste des fichiers rapportés ; les messages, eux, restent échantillonnés d'un run à l'autre, environ 50 à 70 % de l'univers par run, mesuré sur la v0.8.1. Conséquence directe sur la baseline, on y vient dans la section CI. Et re-testez ce comportement à chaque montée de version : la v0.8.6 reste instable même mono-thread.

Les classes readonly sont considérées sûres et skippées. Sur un projet où les services sont final readonly par convention, la couverture réelle porte sur les surfaces non readonly : extensions Twig, validators, types DBAL, subscribers à état. C'est précisément là que vivaient les trois vrais positifs.

CI : cinq étapes sans lesquelles le rapport reste inutilisable

Sans cette discipline, le rapport est trop bruyant pour servir :

  1. Installer le bundle officiel (composer require --dev igor-php/igor-php puis enregistrer le bundle dans config/bundles.php en environnements dev et test).
  2. Compiler le container (cache:clear ou cache:warmup) pour générer var/cache/dev/igor_service_map.json.
  3. Exécuter le scan dans le container, où le service map vit déjà ; à défaut, copier le map vers l'hôte avant le scan.
  4. Première passe : générer la baseline en union de plusieurs runs (voir ci-dessous), pas en un seul --generate-baseline. Et le répertoire est obligatoire en argument : igor --generate-baseline ., sans lui l'outil sort en erreur d'usage.
  5. Gate : GOMAXPROCS=1 igor --baseline igor-baseline.json ., qui échoue uniquement sur les nouveaux findings.

Le point qui n'est pas cosmétique : la baseline. Un --generate-baseline mono-run capture le sous-ensemble de findings de ce run ; au suivant, les messages manqués resurgissent et le gate flappe. La baseline doit être l'union saturée de plusieurs runs : rejouer le scan en JSON jusqu'à trois runs consécutifs sans couple (fichier, message) nouveau, puis écrire l'union. Sur le blog : saturation en 9 runs, 26 fichiers, 111 messages, et un verdict stable vérifié sur 10 runs. Le scan tourne désormais en bloquant dans l'audit hebdomadaire, binaire épinglé sur le composer.lock ; et le paquet est exclu de l'auto-merge Renovate, parce que le matching de baseline est chemin + message exact : une reformulation amont fait tout resurgir.

Dernier réflexe, valable pour n'importe quel gate à baseline : après l'avoir posé, injectez une mutation volontaire dans un service et vérifiez que le scan sort en erreur. Un gate qu'on n'a jamais vu rouge ne prouve rien.

Le mot de la fin

Pas encore, et c'est une réponse posée, pas un rejet. Igor v0.6.0, c'est 148 commits, un seul mainteneur visible, 74 stars : un outil jeune, utile, qui évolue et prometteur. Sur ce blog, le premier scan a remonté trois vrais bugs latents sur 28 fichiers signalés, pour une vingtaine de lignes de fix au total. Le reste est du bruit qu'on apprend à lire : les 22 Twig Components surtout.

Le make qa-deep du blog inclut déjà composer-require-checker, composer-unused, deprecation-detector, qa-unused-public et qa-jscpd. Ajouter Igor demande deux préalables : le bundle officiel installé en dev/test, et un igor-baseline.json propre pour absorber les faux positifs structurels. Tant que ce baseline n'existe pas, le rapport est trop bruyant pour bloquer un build. C'est le prochain chantier.

Sur le fond, ce premier scan confirme une chose : la classe de bug qu'Igor détecte n'est pas un défaut d'inattention, c'est un défaut de modèle mental. En CGI/FPM, chaque requête était un nouveau process, un service pouvait accumuler de l'état sans que ça fuite. Le worker mode invalide ce modèle en silence : ça marche en dev, ça marche en staging, et le pattern reste latent jusqu'au jour où il ne l'est plus. Un outil qui pointe ces cas au build-time, là où PHPStan reste aveugle, vaut le coup d'œil.

(Août 2026 : le setup ci-dessus tourne en v0.8.1, et le projet itère vite ; les mesures de cette page sont datées de leur version.)

PHPStan vérifie ce qu'un type promet ; Igor vérifie ce qu'un service retient. Deux questions différentes. Et toi, tes services partagés en worker mode, tu les as audités ?

Une coquille, une erreur dans ce billet ? Signale-la-moi.

Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).

Vous aimerez aussi

Newsletter

Un mail quand il y a quelque chose à dire

Pas de tracking, pas de pub, pas de "10 astuces pour…". Juste les nouveaux billets et parfois une réflexion en plus.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.