FrankenPHP en dev c'est bien, en prod c'est encore mieux
Déployer un projet Symfony avec FrankenPHP sur VPS : un retour d'expérience qui a survécu aux timeouts SSH.
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.
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.
Parité dev/prod : L'environnement de développement doit être une réplique la plus fidèle possible de l'environnement de production.
Performance et expérience développeur : L'environnement doit être rapide. Attendre plusieurs secondes à chaque rechargement de page freine la productivité.
C'est votre ordinateur de développement. Il contient deux éléments principaux :
À l'intérieur de Docker, deux services fonctionnent et communiquent entre eux :
Service "php" (FrankenPHP)
https://lecodeestdanslepre.local
(ports 80/443).Service "database" (PostgreSQL 16)
La philosophie d'isolation est rendue possible par Docker. N'hésitez pas à relire ce billet et prenez moi un pancake aux myrtilles.
Historiquement, pour faire tourner un site PHP, il fallait deux services distincts :
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).
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.
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.
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.
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.
Le Dockerfile
est la recette pour construire l'image du service php
. Celui de ce projet utilise une technique appelée multi-stage builds.
Imaginez la construction d'un meuble en kit.
C'est exactement ce que fait ce Dockerfile
:
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 (git
, postgresql-client
), des extensions PHP (intl
, opcache
). 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.
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
.
--no-dev
(on exclut PHPUnit, PHPStan, etc.)2.--classmap-authoritative
) pour que Symfony trouve ses classes plus vite3.Le résultat ?
app-php:debug
pour le développement, complète mais plus volumineuse.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.
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 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.
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.
Nous avons deux fichiers pour orchestrer nos services :
compose.yaml
(La base) : Ce fichier définit la structure de tous les services (php
et database
), les volumes (database_data
, caddy_data
) et les images à utiliser. C'est le plan de production.
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 :
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.
# 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.
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.
Enfin, la configuration de PHP lui-même est gérée par des fichiers .ini
.
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 = 256
, opcache.max_accelerated_files = 20000
, etc.).
20-app.dev.ini
(développement) : Ce fichier est chargé uniquement par le stage frankenphp_dev
17. 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.
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.