Vingt requêtes consécutives, vingt connexions MySQL toutes fraîches. C'est PHP-FPM qui fait exactement ce pour quoi il a été conçu : te donner un processus propre, puis tout raser une fois la réponse partie. Les mêmes vingt requêtes contre FrankenPHP en mode worker n'ont ouvert aucune nouvelle connexion, parce que celle établie au démarrage les a toutes servies. La mesure vient d'un retour d'expérience en français sur la migration d'un site Sulu, et c'est l'article le plus convaincant que j'aie lu sur FrankenPHP cette année, précisément parce que son chiffre vedette est petit.

Petit, comme dans 30 à 40 millisecondes gagnées sur une réponse qui tourne désormais autour de 155 ms. Pas le facteur dix des slides de conférence. Et c'est justement cette modestie qui doit t'inspirer confiance. Le mode worker supprime une seule chose : le travail de mise en route que ton framework répète à chaque requête. Il ne fait rien pour ton SQL, rien pour le rendu des templates, rien pour ta logique métier. Si une page passe 400 ms dans la base de données, le mode worker la regardera poliment passer 400 ms dans la base de données. Quiconque te promet davantage est en train de te vendre son harnais de benchmark.

L'objection évidente, c'est qu'on met déjà tout ça en cache. C'est vrai, et ça ne couvre pas la partie coûteuse. OPcache garde le bytecode compilé en mémoire partagée, donc PHP arrête de reparser tes deux mille fichiers. Le conteneur compilé de Symfony dans var/cache/prod/ évite au framework de relire du YAML. Mais sous FPM, les objets construits à partir de tout ce code mis en cache, l'instance du kernel, le conteneur câblé, les métadonnées d'entités de Doctrine, la connexion ouverte à la base, sont détruits à la fin de chaque requête et reconstruits au début de la suivante. Plusieurs mégaoctets d'état préparé, assemblés puis jetés, des centaines de fois par minute. Le mode worker garde ces objets vivants pendant des centaines de requêtes. C'est toute l'astuce, et ça suffit.

Passons à la partie honnête, parce qu'un processus qui se souvient des choses se souvient aussi de tes erreurs. Le back-office du retour d'expérience a cassé d'une façon que je trouve instructive : Sulu décide au boot du kernel s'il sert le site public ou l'admin, un worker fige cette décision pour toujours, et le index.php d'origine contient un exit qui tue net une boucle de worker. Résultat : /admin renvoyait 404 en production. Le correctif a consisté à faire passer l'admin par un front controller classique et à ne garder que le site public dans le worker. La mémoire est l'autre inquiétude, et là les chiffres sont rassurants plutôt que triomphants : 166 MB peu après un redémarrage, 271 MB après environ 18 heures de trafic, avec un plateau bien en dessous d'une limite de 1 GB. Trois garde-fous produisent ce plateau : le runner Symfony appelle gc_collect_cycles() après chaque requête, FrankenPHP recycle chaque worker après 500 requêtes par défaut (réglable via FRANKENPHP_LOOP_MAX), et la limite mémoire du conteneur attrape ce qui s'échappe. Personne ne prétend que tes fuites disparaissent. Le système refuse simplement de les laisser s'accumuler indéfiniment, ce qui est la version adulte de la promesse.

Le piège opérationnel est celui que je ferais tatouer sur chaque script de déploiement : frankenphp reload ne remplace pas tes workers. Il recharge la configuration de Caddy et laisse trois processus accrochés à un kernel qui pointe vers un conteneur de services que ton cache:clear vient de supprimer. Le site répond 500 jusqu'à ce que quelqu'un lance un vrai redémarrage, docker compose restart php dans ce setup. Ajoute à ça opcache.validate_timestamps à 0, qui évite à PHP de faire des stat() sur des milliers de fichiers par requête, mais qui fait aussi que les modifications à la main sur le serveur ne servent silencieusement à rien. Ces deux réglages ne sont sûrs que parce que le déploiement redémarre PHP automatiquement. J'entends l'objection que c'est fragile. Je la retournerais : si ton process dépend encore d'éditer des fichiers sur une machine en prod en croisant les doigts, le mode worker n'a pas créé ce risque, il a juste arrêté de le masquer.

Il y a une victoire plus discrète sous l'histoire du worker. FrankenPHP, c'est Kévin Dunglas qui embarque le moteur PHP dans Caddy, le serveur en Go que Matthew Holt a lancé en 2014, si bien qu'un seul binaire termine désormais le TLS, parle HTTP/1.1 jusqu'à HTTP/3 et sert les fichiers statiques. L'équipe derrière le retour d'expérience a complètement abandonné nginx, et il s'est avéré que c'était le rate limiting de nginx qui avait causé le ralentissement à l'origine de toute l'enquête. Une seule config à lire au lieu de deux endroits où se tromper. Mon détail préféré : des assets brotli précompressés une seule fois au déploiement, à la qualité maximale. Leur feuille de style est passée de 88 KB brute à 24 KB compressée à la volée, puis à 14 KB précompressée. Et oui, il y a quelque chose de satisfaisant à voir PHP emprunter en bloc la machinerie de concurrence de Go, goroutines comprises, sans que personne n'ait à réécrire un contrôleur.

Ce qui m'impressionne le plus, c'est ce qu'ils ont mesuré puis refusé. Le JIT : gain quasi nul, parce qu'une appli web vit dans l'I/O, les chaînes et les tableaux, pas dans les boucles numériques, donc il reste désactivé. opcache.preload : redondant quand trois workers résidents gardent déjà le kernel chaud, et risqué avec la lecture d'annotations de Sulu. Un second cache HTTP dans Caddy : rejeté, parce que le cache de Symfony sait déjà s'invalider tout seul et qu'un second cache, c'est un second chemin d'invalidation à garder honnête. C'est de l'ingénierie par soustraction, et c'est plus rare que ça ne devrait l'être.

Le bilan se lit donc ainsi : environ 190 ms ramenées à 155 ms, à peu près 140 MB de RAM détenus en permanence par trois workers, et un pipeline de déploiement qui doit redémarrer PHP sans humain dans la boucle. Sur ces preuves, je prendrais ce deal sur la plupart des applis Symfony ou Laravel qui ont de la mémoire en réserve, et je le laisserais de côté pour tout ce qui s'appuie sur des statiques qui s'accumulent ou des exit disséminés. Mais je sais que mon seuil n'est pas le tien. Est-ce que tu immobiliserais 140 MB de mémoire permanente pour gagner 35 ms par requête, et si tu as déjà basculé, quelle est la première chose qui a cassé quand ton application s'est soudain souvenue de la requête précédente ?