Comment FrankenPHP a relégué PHP-FPM et Nginx au stade de reliques

Temps de lecture : 13 min

Penser encore à Nginx et PHP-FPM aujourd'hui, c'est comme s'obstiner à utiliser un Minitel alors que la fibre est installée. J'ai tout remplacé par FrankenPHP et j'ai mon ancienne stack au musée.

En développement web, l'environnement de travail local c'est son pré carré (haha <pre> tu l'as ?) avec de jolis poneys. C'est là que tout se construit, se teste (oui oui) et se peaufine avant la mise en production. Plus votre pré sera beau, et plus vos poneys seront heureux.

Après avoir vu différentes vidéos du PHPverse 2025, j'ai jeté mon dévolu sur FrankenPHP. Après l'avoir utilisé en dev et aussi en prod, c'est le genre de petits bijoux qu'on regrette de ne pas avoir découvert plus tôt.

Après des déconvenues et de grosses déceptions avec la plateforme qui hébergeait mon site, j'ai donc décidé d'essayer FrankenPHP sur ma stack Docker.

La vue d'ensemble

Avant de parler "outil", parlons "philosophie" :

  • Isolation complète : Chaque service (le site web, la base de données) vit dans sa propre "boîte" hermétique, un conteneur. Rien n'est installé directement sur l'ordinateur du développeur, à part l'outil pour gérer ces boîtes (Docker). N'oubliez pas l'analogie avec les pancakes.

    • Pourquoi ? Pour éradiquer le célèbre "Mais... ça marche sur ma machine !". Si l'application fonctionne dans le conteneur, elle fonctionnera de la même manière chez un autre développeur ou (presque) en production. Cela évite les conflits de versions PHP, de bibliothèques système, etc.
  • Parité dev/prod : L'environnement de développement doit être une réplique la plus fidèle possible de l'environnement de production.

    • Pourquoi ? Pour chasser les mauvaises surprises. Utiliser SQLite en développement et PostgreSQL en production est une recette pour découvrir des incompatibilités au dernier moment. Ici, FrankenPHP, Docker et PostgreSQL sont utilisés partout. Seules les configurations changent (par exemple, activer le débogage en "dev", l'optimiser en "prod").
  • Performance et expérience développeur : L'environnement doit être rapide. Attendre plusieurs secondes à chaque rechargement de page freine la productivité.

    • Pourquoi ? Un développeur productif est un développeur qui n'est pas ralenti par ses outils, et surtout qui ne hurle pas comme un putois en demandant "pourquoi" après chaque virgule. Des fonctionnalités comme le hot reload (le code se rafraîchit automatiquement) sont ici centrales.

L'architecture

L'hôte

C'est votre ordinateur de développement. Il contient deux éléments principaux :

  • Votre éditeur de code (IDE)
  • Docker (qui gère le réseau et les services ci-dessous)

Environnement Docker (réseau privé)

À l'intérieur de Docker, deux services fonctionnent et communiquent entre eux :

  • Service "php" (FrankenPHP)

    • Comprend un serveur Caddy, PHP 8.4 et Xdebug.
    • Ce service est mappé sur votre machine hôte :
      • Accès Web : https://lecodeestdanslepre.local (ports 80/443).
  • Service "database" (PostgreSQL 16)

    • Expose son port 5432 uniquement à l'intérieur du réseau Docker.
    • N'est pas accessible directement depuis votre machine Hôte.

Flux de communication

  • PHP ➔ Base de données : Le service php contacte le service database en utilisant simplement le nom d'hôte database (sur le port 5432).
  • Débogage (Xdebug) : Votre IDE se connecte à Xdebug (dans le service php) via le port 9003 pour vous permettre de déboguer le code.

Le cœur de la fusée : Docker et Docker Compose

La philosophie d'isolation est rendue possible par Docker. N'hésitez pas à relire ce billet et prenez moi un pancake aux myrtilles.

Le moteur applicatif : FrankenPHP, Caddy et PHP 8.4

L'approche traditionnelle : Nginx + PHP-FPM

Historiquement, pour faire tourner un site PHP, il fallait deux services distincts :

  • Un serveur web (comme Nginx ou Apache) qui reçoit la requête.
  • Un gestionnaire de processus PHP (PHP-FPM) à qui le serveur web transmet la requête. PHP-FPM exécute le script index.php, récupère le résultat HTML, et le renvoie au serveur web, qui le renvoie au client.

C'est robuste, mais plus complexe à configurer : deux services à superviser, des sockets (pas les chaussettes hein) à gérer et lent au démarrage de chaque requête (le bootstrap de Symfony).

L'approche moderne : FrankenPHP

FrankenPHP est un serveur d'application PHP moderne, écrit en Go, et bâti sur le serveur web Caddy.

C'est un monstre de performance, mais un gentil monstre. Ce n'est plus un serveur web ET un gestionnaire PHP. C'est un seul binaire, un seul conteneur, qui fait les deux.

Qu'est-ce que Caddy ?

Caddy est un serveur web nouvelle génération. Sa philosophie est la simplicité. Le Caddyfile du projet est court et lisible.

Ses atouts principaux :

  • HTTPS automatique : En production, il gère seul les certificats SSL/TLS avec Let's Encrypt.

  • HTTPS local (tls internal) : En développement, il génère des certificats auto-signés mais approuvés par la machine locale. C'est pour cela que le site est accessible en https://lecodeestdanslepre.local avec un cadenas vert, sans alerte de sécurité. C'est un confort de développement notable.

  • HTTP/3 : Il gère les protocoles web les plus modernes nativement.

Qu'est-ce que FrankenPHP ajoute à Caddy ?

Il s'intègre à Caddy comme un module PHP natif. Mais sa vraie force, c'est son mode Worker.

En production (et c'est là que la parité dev/prod est intéressante), FrankenPHP peut démarrer des "workers" Symfony.

  • Mode classique (CGI) : 1 requête = 1 démarrage de Symfony (lire la config, charger les services...) -> 1 réponse -> 1 arrêt de Symfony.
  • Mode Worker (Keep-Alive) : FrankenPHP démarre Symfony une seule fois et le garde "chaud" en mémoire. Quand une requête arrive, il la lui donne directement, sans la phase de démarrage.

L'impact sur les performances est significatif.

En développement, le Dockerfile utilise la commande CMD ["frankenphp", "run", ..., "--watch"]1. Le drapeau --watch est très efficace : FrankenPHP surveille les fichiers PHP. Dès qu'une sauvegarde est faite, il redémarre "à chaud" ses workers. La modification est prise en compte instantanément.

L'art de bâtir : Le Dockerfile multi-stage

Le Dockerfile est la recette pour construire l'image du service php. Celui de ce projet utilise une technique appelée multi-stage builds.

L'analogie de l'atelier

Imaginez la construction d'un meuble en kit.

  • Stage 1 (L'atelier) : Pour la construction, on a besoin d'une grosse caisse à outils : scies, marteaux, colle, vis, plans... C'est salissant et volumineux.
  • Stage 2 (Le salon) : Une fois le meuble (une bibliothèque) construit, on ne garde pas la scie et la colle dans le salon. On y place uniquement la bibliothèque finie.

C'est exactement ce que fait ce Dockerfile :

Stage 1 : frankenphp_base et frankenphp_dev

FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
...
FROM frankenphp_upstream AS frankenphp_base
...
RUN apt-get install -y ... git postgresql-client
RUN install-php-extensions ... apcu intl opcache zip 
...
FROM frankenphp_base AS frankenphp_dev
...
RUN install-php-extensions xdebug pdo_pgsql [cite: 5]

C'est l'atelier. On part de l'image de base (frankenphp_upstream), on installe des outils système (gitpostgresql-client), des extensions PHP (intlopcache). Puis, dans un stage _dev, on ajoute l'outil le plus lourd : Xdebug (le débogueur). On installe aussi toutes les dépendances Composer. Cette image frankenphp_dev est complète et contient tous les outils de développement.

Stage 2 : frankenphp_prod

FROM frankenphp_base AS frankenphp_prod

ENV APP_ENV=prod
# On n'installe PAS Xdebug
...
# On installe Composer SANS les dépendances de dev
RUN composer install --no-dev ... 
...
# On optimise tout
RUN composer dump-autoload --classmap-authoritative 
RUN composer dump-env prod 

C'est le salon. On repart du stage frankenphp_base (qui a le strict minimum), mais on ignore tout le stage _dev.

  • On n'installe pas Xdebug (inutile et risqué en production).
  • On installe les dépendances Composer avec --no-dev (on exclut PHPUnit, PHPStan, etc.)2.
  • On génère des optimisations d'autoloading (--classmap-authoritative) pour que Symfony trouve ses classes plus vite3.

Le résultat ?

  • Une image app-php:debug pour le développement, complète mais plus volumineuse.
  • Une image app-php:prod qui est svelte, sécurisée (moins d'outils) et optimisée pour la vitesse.

C'est une pratique recommandée pour la construction d'images de conteneurs.

La base de données : PostgreSQL 16

Le choix de la base de données est PostgreSQL (surnommé "Postgres").

Pourquoi ? PostgreSQL est une base de données relationnelle open-source extrêmement puissante, réputée pour sa robustesse, son respect des standards SQL et ses fonctionnalités avancées (gestion fine du JSON, types de données complexes, etc.). C'est un choix de premier plan, souvent préféré à MySQL pour les applications nécessitant une grande intégrité de données.

Le healthcheck : la patience est une vertu

Le compose.yaml définit un healthcheck pour la base de données :

healthcheck:
    test: [ "CMD", "pg_isready", "-d", "${POSTGRES_DB:-app_dev}", "-U", "${POSTGRES_USER:-app}" ]

Traduction : "Docker, toutes les quelques secondes, exécute la commande pg_isready dans le conteneur database. Cette commande vérifie si Postgres est vraiment prêt à accepter des connexions."

Le script docker-entrypoint.sh du service php utilise cette information. Il contient une boucle qui dit : "Attends que le healthcheck de la base de données soit 'healthy' avant de continuer".

Pourquoi ? Un conteneur de base de données peut mettre 5 à 10 secondes à démarrer. Le conteneur php démarre en 1 seconde. Sans cette attente, le script php essaierait de se connecter à une base de données qui n'est pas encore prête, ce qui provoquerait un crash. Ce "healthcheck" synchronise le démarrage des services.

Plongée dans les fichiers de configuration (Dev vs Prod)

La parité dev/prod ne signifie pas "fichiers identiques", mais "comportement prévisible". La magie opère en utilisant des fichiers de configuration spécifiques qui s'appuient sur une base commune.

Docker Compose : L'orchestrateur

Nous avons deux fichiers pour orchestrer nos services :

  1. compose.yaml (La base) : Ce fichier définit la structure de tous les services (php et database), les volumes (database_datacaddy_data) et les images à utiliser. C'est le plan de production.

  2. compose.dev.yaml (La surcharge) : Ce fichier surcharge le premier. Quand on lance docker compose -f compose.yaml -f compose.dev.yaml up, Docker fusionne les deux, et compose.dev.yaml a le dernier mot.

Voici ce que compose.dev.yaml modifie pour le développement :

Service php (Le site)

services:
    php:
        build:
            context: .
            dockerfile: devops/frankenphp/${DOCKERFILE:-Dockerfile}
            target: ${BUILD_TARGET:-frankenphp_dev}
        image: ${IMAGES_PREFIX:-}app-php${IMAGE_SUFFIX:-:dev}
        restart: unless-stopped
        environment:
	        APP_ENV: dev
            SERVER_NAME: ${SERVER_NAME:-lecodeestdanslepre.local}, php:80
            DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app_dev}?serverVersion=${POSTGRES_VERSION:-16}&charset=${POSTGRES_CHARSET:-utf8}
            SYMFONY_VERSION: ${SYMFONY_VERSION:-}
            STABILITY: ${STABILITY:-stable}
            # Xdebug configuration
			XDEBUG_MODE: "${XDEBUG_MODE:-debug}"
			XDEBUG_CONFIG: "client_host=host.docker.internal client_port=9003"
        volumes:
            - caddy_data:/data
            - caddy_config:/config
            # Mount source code for hot reload
            - ./:/app:cached
            - /app/var
        ports:
            # HTTP
            -   target: 80
                published: ${HTTP_PORT:-80}
                protocol: tcp
            # HTTPS
            -   target: 443
                published: ${HTTPS_PORT:-443}
                protocol: tcp
            # HTTP/3
            -   target: 443
                published: ${HTTP3_PORT:-443}
                protocol: udp
		extra_hosts:
			- host.docker.internal:host-gateway
		tty: true

    database:
        image: postgres:${POSTGRES_VERSION:-16}-alpine
        environment:
            POSTGRES_DB: ${POSTGRES_DB:-app_dev}
            POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
            POSTGRES_USER: ${POSTGRES_USER:-app}
        healthcheck:
            test: [ "CMD", "pg_isready", "-d", "${POSTGRES_DB:-app_dev}", "-U", "${POSTGRES_USER:-app}" ]
            timeout: 5s
            retries: 5
            start_period: 60s
        volumes:
            - database_data:/var/lib/postgresql/data:rw
		ports:
			- "5432"

volumes:
    caddy_data:
    caddy_config:
    database_data:
  • volumes: ./:/app:cached : C'est la ligne la plus importante. Au lieu d'utiliser le code copié dans l'image (comme en production 4), on "monte" le dossier local (.) directement dans le conteneur (/app). Chaque modification dans l'IDE est instantanément vue par le conteneur. C'est la clé du hot-reload.

  • environment : On force l'environnement Symfony à dev et on active le débogage (APP_DEBUG: 1).

  • XDEBUG_MODE et XDEBUG_CONFIG : On active Xdebug et on lui dit où se trouve l'IDE (client_host=host.docker.internal).

  • extra_hosts : On ajoute une "entrée DNS" magique pour que, depuis le conteneur, host.docker.internal pointe vers la machine hôte (votre ordinateur). C'est ce qui permet à Xdebug de "rappeler" votre IDE.

J'ai utilisé dunglas/frankenphp-demo pour la configuration.

Service database (Postgres)

# compose.dev.yaml
  database:
    environment:
      POSTGRES_DB: dev
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
    ports:
      - "5432"
  • environment : On surcharge les identifiants pour utiliser des valeurs simples (dev/dev/dev), qui correspondent à la variable DATABASE_URL définie dans le service php.

  • ports: - "5432" : On expose le port 5432 de Postgres sur un port aléatoire de la machine hôte. Cela permet de se connecter à la base de données avec un outil graphique (comme TablePlus, mon miroir magique..) depuis l'extérieur de Docker, un confort appréciable en développement.

Configuration Caddy : le serveur web

Caddyfile.dev : Ce fichier est chargé par le stage frankenphp_dev du Dockerfile. Il est bien plus simple et vise l'opposé de la production :

# Caddyfile.dev
{
    frankenphp
    local_certs 
}

lecodeestdanslepre.local {
    tls internal 
    log {
        level DEBUG 
    }

    # Handle static assets with cache headers for development
    handle /bundles/* {
        header Cache-Control "no-cache, no-store, must-revalidate" [cite: 17]
        file_server
    }
    
    # ... autres handle ...

    # AssetMapper assets in dev mode
    handle /assets/* {
        header Cache-Control "no-cache, no-store, must-revalidate" 
        php_server 
    }

    php_server 
}
  • local_certs et tls internal : C'est la magie qui génère un certificat SSL/TLS local, pour que https://lecodeestdanslepre.local fonctionne avec un cadenas vert10.

  • log { level DEBUG } : On demande à Caddy d'être très bavard, ce qui aide à déboguer les requêtes11.

  • header Cache-Control "no-cache..." : À l'exact opposé de la production, on désactive le cache du navigateur pour tous les assets12. Cela garantit que chaque modification d'un fichier CSS ou JS est immédiatement visible au rechargement.

  • php_server : On utilise le mode php_server 13simple (pas le mode worker 14) car il est plus facile à déboguer et fonctionne parfaitement avec le drapeau --watch de FrankenPHP15.

Configuration PHP : le débogage

Enfin, la configuration de PHP lui-même est gérée par des fichiers .ini.

  1. 10-app.ini (base) : Ce fichier est chargé par tous les environnements (dev et prod)16. Il contient les optimisations de performance recommandées par Symfony, notamment pour Opcache (opcache.memory_consumption = 256opcache.max_accelerated_files = 20000, etc.).

  2. 20-app.dev.ini (développement) : Ce fichier est chargé uniquement par le stage frankenphp_dev17. Son seul et unique but est de configurer Xdebug :

; 20-app.dev.ini
xdebug.client_host = host.docker.internal
  • xdebug.client_host : Comme dans le compose.dev.yaml, on dit à Xdebug que l'IDE qui l'écoute se trouve à l'adresse host.docker.internal.

Cette séparation nette des configurations permet d'avoir un environnement de développement riche en outils (Xdebug, logs, hot-reload) tout en garantissant un environnement de production svelte, sécurisé et optimisé pour la vitesse.

Le mot de la fin

L'alliance de FrankenPHP et d'une config Docker bien pensée pour la parité dev/prod change la vie.

Alors oui, pour moi qui suis une quiche en devops, ça n'a pas été évident ; je ne compte plus le nombre de fois où j'ai spéculé sur le métier de personnes que je ne connaissais même pas. J'y passé le temps, mais le gain de performance en vaut le coup et ce genre de choses qui me font aimé de plus en plus mon métier.