Aller au contenu principal

FrankenPHP en dev : un worker chaud, trois --watch empilés, et un cert qui ne demande rien.

Découvrez FrankenPHP en développement : un worker chaud, trois mécanismes de hot reload empilés et un certificat automatique. Comprenez comment FrankenPHP fonctionne et ce que ses fonctionnalités offrent côté développement.

MAJ 11 min de lecture
Sommaire · 7

Ce site a basculé sur FrankenPHP en octobre 2025, le jour où j'ai changé de provider pour un VPS — pas un choix réfléchi, plutôt l'opportunité d'une reconstruction de zéro qui m'a permis de partir sur autre chose que la pile que je traînais.

Le confort dev est venu après, par accumulation : --watch ajouté à l'image dev quand FrankenPHP a sorti la fonctionnalité, tailwind:build --watch lancé par l'entrypoint quand le bundle Symfony Tailwind l'a permis, puis worker { watch } et php { hot_reload } au gré des releases suivantes du binaire ; ce qui fait trois mécanismes de hot reload empilés dans la même image.

Le pendant côté dev de ce blog FrankenPHP manquait. Ce billet raconte la prod ; celui-ci raconte ce que la même stack donne en dev — là où on tape make up, où on sauve un fichier, où le navigateur recharge sans qu'on ait à y penser. On ne rouvre pas le débat « 3,5× plus rapide que FPM » : il est désamorcé côté prod. Ce billet présente ce qu'est FrankenPHP, ce que ses trois --watch font vraiment, comment Tailwind tient dans le même process, et pourquoi https://localhost:8443 répond avec un cadenas sans qu'on ait bricolé un /etc/hosts.

Qu'est-ce que FrankenPHP, brièvement

FrankenPHP, c'est un binaire écrit en Go qui embarque le serveur HTTP Caddy et lie libphp en C. Un seul exécutable ouvre le port 443, parse le Caddyfile, et exécute le PHP. Là où l'orthodoxie historique demandait Nginx d'un côté pour parler HTTP et PHP-FPM de l'autre pour exécuter le code — avec une socket Unix entre les deux et deux unités systemd à surveiller — on a maintenant un process unique.

La vraie bascule n'est pas dans le merge des deux services. C'est dans le mode worker. En FPM, chaque requête entrante réveille un process PHP, qui charge l'autoloader, qui boot Symfony, qui répond, qui meurt. La requête suivante recommence à zéro. En mode worker, FrankenPHP démarre Symfony une seule fois et le garde chaud en mémoire. Les requêtes suivantes réveillent un Kernel déjà compilé.

Pour les fondations infra de tout ça — opcache.preload, la compilation xcaddy custom, le multi-host à quatre sous-domaines — le billet prod du 16 mai a déjà fait le travail. Le reste de ce billet regarde ce que le worker mode donne à l'usage, dans le quotidien dev. Là où on tape make up et où on a besoin que ça se recharge tout seul.

Le worker chaud, aussi en dev

Le bloc qui décide du worker mode vit dans le Caddyfile, en dix lignes :

# devops/images/frankenphp/Caddyfile (extrait)
@frontController path /index.php
php @frontController {
    {$FRANKENPHP_SITE_CONFIG}

    worker {
        file ./public/index.php
        {$FRANKENPHP_WORKER_CONFIG}
    }
}

Ce bloc est le même fichier en dev et en prod. C'est important : on ne lit pas deux Caddyfiles « qui se ressemblent », on lit exactement le même. Les seules variations sont les deux variables d'environnement FRANKENPHP_SITE_CONFIG et FRANKENPHP_WORKER_CONFIG, posées différemment selon le stage Docker.

Conséquence en dev : Symfony est chargé une seule fois au démarrage du container. Chaque F5 attaque un Kernel déjà chaud, un container Symfony déjà compilé, un router déjà matché. Le temps gagné par requête n'est pas spectaculaire pour le développeur — on ne s'aperçoit jamais qu'on a gagné 50 ms — mais il devient visible quand on enchaîne dix navigations rapides pour tester un workflow.

Le piège est inverse de la prod : en prod, on veut que le worker garde tout son état figé entre deux requêtes — preload immobile, opcache validate_timestamps = 0, le moindre stat() syscall économisé compte. En dev, on veut l'inverse : que chaque sauvegarde du fichier rebatte les cartes, redémarre le worker, recharge le bytecode. Sans ce mécanisme, le mode worker rendrait l'expérience dev infernale — on éditerait du code que le serveur ne servirait jamais.

D'où la nécessité du hot reload. Qui, dans FPM, n'existait pas — et n'avait pas à exister, puisque FPM ne gardait rien entre requêtes. Le confort qu'on avait par défaut, il a fallu le reconstruire.

Les trois --watch qu'on ne voit pas

C'est là que je suis allé chercher. Le stage dev du Dockerfile fait trois choses qui se complètent — sans qu'aucun commentaire dans le repo ne les nomme ensemble :

Dockerfile
# devops/images/frankenphp/Dockerfile (stage frankenphp_dev)
ENV FRANKENPHP_WORKER_CONFIG=watch
ENV FRANKENPHP_SITE_CONFIG=hot_reload
# ...
CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--watch" ]

watch dans le bloc worker (injecté par FRANKENPHP_WORKER_CONFIG=watch) agit au niveau du worker Symfony. C'est plus fin : le worker se recycle quand ses fichiers sources changent, sans rebooter le serveur HTTP qui l'héberge. La requête suivante atterrit sur un Kernel fraîchement instancié, mais Caddy n'a pas eu à se relire.

hot_reload dans la directive php (injecté par FRANKENPHP_SITE_CONFIG=hot_reload) agit au niveau de la conf Caddy. Il permet à Caddy de prendre en compte les modifications de directives sans plein redémarrage — pratique quand on touche au Caddyfile lui-même, ce qui arrive rarement en dev mais reste un confort.

Les trois sont empilés parce qu'ils couvrent trois cas distincts : changement de Caddyfile, changement de classe Symfony chargée par le worker, changement de fichier PHP arbitraire. Aucun ne couvre les trois cas seul. On les met tous, on n'y pense plus.

Un détail qu'on oublie facilement : opcache.validate_timestamps = 1 côté dev (vs 0 en prod, expliqué dans le billet prod). Sans ce flag, FrankenPHP redémarrerait son worker mais opcache, fidèle au bytecode déjà en mémoire, resservirait l'ancien. Les trois --watch ne servent à rien si opcache campe sur ses positions.

Tailwind dans le même container que le worker

Le watcher Tailwind n'est pas dans le Dockerfile. Il est dans l'entrypoint shell :

Terminal
# devops/images/frankenphp/docker-entrypoint.sh (extrait)
if [ "$APP_ENV" = "dev" ] && [ "$1" = 'frankenphp' ]; then
    mkdir -p var/log

    echo 'Starting Tailwind watcher...'
    php bin/console tailwind:build --watch >> var/log/tailwind.log 2>&1 &

    # ... + Messenger consumers
fi

Lancé en background, en même temps que FrankenPHP, dans le même container, contre le même bind-mount /app. Quand on change une classe Tailwind dans un Twig, Tailwind voit le fichier modifié, recompile le CSS dans public/build/, et le worker resert le nouveau CSS sur la requête suivante.

Pourquoi dans le même container ? L'alternative, c'est un container Node séparé qui watche les Twig via un volume partagé. Ça marche, mais ça demande un docker-compose à deux services au lieu d'un, des permissions cohérentes entre les deux UID, et une couche de synchro qui ajoute du retard. Le bundle Symfony tailwindcss est un wrapper PHP sur le binaire Tailwind standalone — il s'exécute là où Symfony s'exécute, donc dans le container PHP. On évite le container Node, et avec lui une catégorie entière de problèmes de tooling JS.

Combo avec --watch PHP : éditer un Twig, sauvegarder, le navigateur recharge — du CSS frais sur un worker chaud. Aucun outil JS dans le pipeline dev, aucun watcher à lancer à la main, aucune fenêtre de terminal supplémentaire à garder ouverte.

Une note honnête : c'est le seul endroit où la parité dev/prod est délibérément cassée. En prod, ce process Tailwind n'existe pas — l'asset map est précompilée au stage frankenphp_prod_builder du Dockerfile, et le CSS gravé dans l'image finale est statique. Casser la parité ici est justifié : en prod, personne ne sauve de Twig. Le watcher serait du temps machine pour rien.

Le cert auto-signé qui ne demande rien

L'autre confort dev qu'on banalise, c'est le cert HTTPS qui marche sans rien préparer :

# devops/images/frankenphp/Caddyfile (extrait, lignes 3-5 + 68-72)
{
    skip_install_trust
    # ...
}

{$SERVER_NAME:localhost},
admin.{$APP_DOMAIN:localhost},
mcp.{$APP_DOMAIN:localhost},
media.{$APP_DOMAIN:localhost},
php:80 {
    # ...
}

skip_install_trust dit à Caddy : « ne touche pas au trust store de l'host, sois autonome ». Sans ce drapeau, Caddy tenterait d'installer son CA racine sur le système hôte — chose impossible depuis un container, et invasive même sur l'host. À la place, il signe ses propres certs pour localhost et chaque sous-domaine déclaré.

Conséquence : https://localhost:8443, https://admin.localhost:8443, https://mcp.localhost:8443, https://media.localhost:8443. Quatre sous-domaines, quatre certs auto-signés. Le navigateur affiche son écran d'avertissement la première fois sur chacun, on clique « accepter », fini. La session s'en souvient, le routage Symfony scope les hosts comme en prod (host: 'admin.%env(APP_DOMAIN)%'), et on développe l'admin EasyAdmin contre un host différent du front public — exactement comme en prod.

Le multi-host décrit dans le billet prod fonctionne aussi en dev, parce que le Caddyfile est littéralement le même fichier. Pas de Caddyfile dev qui simplifie en monorout. Pas de variante de configuration qui masquerait un bug spécifique au multi-host. La parité de stack n'est pas « les configs se ressemblent » — c'est « c'est le même Caddyfile, point ».

Comparaison terre-à-terre : sur un setup Nginx local classique, on a le choix entre du HTTP en clair sur :8000, ou installer mkcert à la main avec son CA racine. Le confort de FrankenPHP en dev n'est pas dans le HTTPS lui-même — c'est dans le fait qu'on n'y pense pas.

La session qui survit aux reloads, et celle qui ne survit pas

Une nuance qui mérite d'être posée, parce qu'elle a mordu plus d'une fois.

Les sessions PHP de ce projet ne vivent pas dans le process FrankenPHP. Elles vivent dans Redis — sur l'instance redis-state, configurée pour ne jamais évincer (cf. l'architecture Redis dual du blog). Donc une session admin survit à tous les reloads de code que le watch peut déclencher : on s'authentifie une fois, on édite du PHP, le worker redémarre, on reste connecté.

En revanche : un service singleton instancié au boot() du Kernel reste vivant tant que le worker n'a pas redémarré. --watch règle ce cas en dev — chaque sauvegarde déclenche le recyclage. En prod, on déploie une nouvelle image et le nouveau worker recharge tout.

La mise en garde reprise au billet prod tient ici aussi : variables statiques de classe, caches in-memory mal taggés, propriétés qui s'accrochent à un objet partagé entre requêtes — « le code qui range un état dans une statique devient une bombe à retardement ». En dev, on les voit grâce à --watch parce qu'à chaque save, le worker repart de zéro et l'état pourri disparaît. En prod, on les voit grâce à un incident.

C'est probablement le seul effet du worker mode qui mérite, en dev, qu'on garde l'œil ouvert. Le reste est gratuit.

Le mot de la fin

Avant FrankenPHP, monter un environnement dev qui ressemble vraiment à la prod, c'était un projet en soi. On choisissait son axe : soit on copiait l'image prod en y rajoutant des outils (Xdebug, watchers, mkcert), au prix d'une image dev lourde et d'un Dockerfile dev qui divergeait du Dockerfile prod ; soit on construisait un dev léger avec Nginx local ou PHP intégré, au prix d'une non-parité acceptée qui finissait par se payer en bugs spécifiques à la prod.

FrankenPHP renverse la prémisse. Le binaire est le même. Le Caddyfile est le même. Le mode worker est le même. Ce qui change tient en trois variables d'environnement (FRANKENPHP_SITE_CONFIG=hot_reload, FRANKENPHP_WORKER_CONFIG=watch, XDEBUG_MODE=off), un drapeau (--watch), un fichier php.ini qui hérite de php.ini-development au lieu de php.ini-production, et un watcher Tailwind lancé dans un if shell de douze lignes. C'est tout.

La distance dev/prod ne fond pas en uniformisant les deux — elle fond en partageant le socle et en n'exposant que des drapeaux à la surface. Le confort dev n'est pas un compromis avec la prod ; il est une dérivation pilotée par cinq points de configuration.

Le binaire est le même. Ce qui change, ce sont trois drapeaux et un watcher.

Reste à savoir si la prochaine étape — où le worker chaud rencontre Stimulus, LiveComponent et un round-trip serveur qui devient si court qu'on hésite à le qualifier de round-trip — n'est pas en train de déplacer, elle aussi, la frontière front/back.

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 anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.