FrankenPHP avec Symfony 7.4 : mode classique, Worker et benchmark concret

Interface du catalogue Symfony exécutée avec FrankenPHP en mode Worker
Catalogue de produits Symfony : formulaire, API REST et benchmark en mode Worker.

FrankenPHP permet d’exécuter une application Symfony sans installer PHP-FPM, Nginx, Apache ou le serveur local de Symfony. Pour comprendre concrètement son fonctionnement, j’ai monté la même application Symfony 7.4 dans deux configurations : le mode classique et le mode Worker.

Le projet final expose une API REST avec Doctrine et SQLite, une interface Twig, un tableau de bord météo alimenté par une API externe et un benchmark directement accessible depuis le navigateur.

FrankenPHP remplace-t-il PHP-FPM ?

FrankenPHP ne se contente pas de remplacer le serveur de développement de Symfony. Il intègre Caddy et le moteur PHP dans un même programme.

Navigateur
   ↓ HTTP
Caddy intégré à FrankenPHP
   ↓
Moteur PHP intégré
   ↓
Kernel Symfony
   ↓
Contrôleur → Response

Il n’y a donc ni FastCGI ni processus PHP-FPM externe. FrankenPHP reçoit directement la requête HTTP, exécute PHP et renvoie la réponse.

Lancer Symfony avec FrankenPHP

Depuis la racine du projet Symfony, il faut monter le projet entier dans /app. La racine publique reste /app/public.

docker run --rm -d \
  --name franken-symfony \
  -e SERVER_NAME=:80 \
  -e APP_ENV=dev \
  -v "$PWD:/app" \
  -p 8384:80 \
  dunglas/frankenphp

L’application est alors disponible sur http://localhost:8384. Aucun symfony server:start ni php -S n’est nécessaire.

Mode classique : un cycle par requête

En mode classique, le processus FrankenPHP reste vivant, mais Symfony est initialisé pour chaque requête. Ce cycle ressemble à celui d’une application servie par PHP-FPM, même si l’architecture interne est différente.

Requête 1 → initialisation Symfony → contrôleur → réponse → nettoyage
Requête 2 → initialisation Symfony → contrôleur → réponse → nettoyage

Une variable statique définie dans un contrôleur revient donc à sa valeur initiale lors de chaque requête.

Mode Worker : Symfony reste en mémoire

Depuis Symfony 7.4, le mode Worker de FrankenPHP est supporté nativement. Il suffit d’ajouter FRANKENPHP_CONFIG :

docker run --rm -d \
  --name franken-symfony-worker \
  -e SERVER_NAME=:80 \
  -e APP_ENV=dev \
  -e "FRANKENPHP_CONFIG=worker ./public/index.php 10" \
  -v "$PWD:/app" \
  -p 8385:80 \
  dunglas/frankenphp

Ici, dix workers traitent les requêtes. Symfony est démarré une fois par worker et reste chargé en mémoire.

Démarrage du worker → initialisation Symfony
   ├── requête 1 → réponse
   ├── requête 2 → réponse
   └── requête 3 → réponse

Une démonstration simple avec un compteur

#[Route('/demo/compteur')]
public function compteur(): JsonResponse
{
    static $nombre = 0;
    ++$nombre;

    return new JsonResponse(['compteur' => $nombre]);
}

En mode classique, le résultat reste 1, 1, 1. Avec un seul Worker, il devient 1, 2, 3. Avec plusieurs workers, chaque worker possède son propre compteur.

Cet exemple montre aussi le principal risque du mode Worker : une variable statique, globale ou un service avec état peut conserver des informations entre deux requêtes. Il ne faut jamais y stocker des données propres à un utilisateur. Symfony réinitialise ses principaux services, mais les services applicatifs avec état doivent être conçus avec attention.

Une vraie API REST avec Doctrine

Pour dépasser le simple « Hello World », j’ai créé une API de catalogue persistée dans SQLite :

  • GET /api/products : liste et recherche ;
  • GET /api/products/{id} : détail ;
  • POST /api/products : création ;
  • PATCH /api/products/{id} : modification partielle ;
  • DELETE /api/products/{id} : suppression.

Les deux conteneurs utilisent la même base SQLite. Un produit créé via le mode classique peut immédiatement être lu via le mode Worker. Le mode d’exécution change le cycle de vie de PHP et Symfony, pas le contrat HTTP ni les données métier.

Interface du catalogue Symfony exécutée avec FrankenPHP en mode Worker
Catalogue de produits Symfony : formulaire, API REST et benchmark en mode Worker.

Appeler une API externe et afficher des graphiques

Un second exemple utilise le composant HttpClient de Symfony pour interroger Open-Meteo. L’utilisateur saisit une ville, Symfony recherche ses coordonnées, récupère les prévisions sur 24 heures puis normalise la réponse JSON.

Code source Symfony du contrôleur météo utilisant HttpClient et Open-Meteo
Extrait du contrôleur Symfony : géocodage, appel Open-Meteo et réponse JSON.
Navigateur
   ↓
GET /api/weather?city=Paris
   ↓
Contrôleur Symfony
   ├── API de géocodage Open-Meteo
   └── API de prévisions Open-Meteo
   ↓
JSON normalisé
   ↓
Graphiques température et pluie

L’interface Twig dessine ensuite un graphique de température et un histogramme des probabilités de pluie. Ce scénario est intéressant pour comprendre qu’une API Symfony sert souvent de couche intermédiaire entre le navigateur, les règles métier et plusieurs services externes.

Tableau de bord météo Symfony avec graphiques de température et de pluie
Résultat dans le navigateur : données Open-Meteo et graphiques sur 24 heures.

Benchmark : classique contre Worker

Pour comparer correctement les deux modes, le Worker a été configuré avec dix instances et le benchmark a envoyé 1 000 requêtes avec une concurrence de 10.

Mode classique : environ 874 requêtes/seconde
Mode Worker    : environ 1 800 requêtes/seconde

Sur ce petit endpoint JSON, le mode Worker était donc environ 2,06 fois plus rapide. Ce résultat n’est pas une vérité universelle : il dépend de la machine, du nombre de workers, du mode debug et du code testé.

Le gain est particulièrement visible sur les réponses rapides, car le coût d’initialisation de Symfony représente une part importante du traitement. Si une requête attend surtout une base distante ou une API externe lente, le gain relatif sera plus faible.

Quel mode choisir ?

Le mode classique reste très simple et offre une isolation naturelle entre les requêtes. Il constitue un bon choix pour tester une application existante ou une dépendance dont la compatibilité avec les processus persistants n’est pas garantie.

Le mode Worker offre de meilleures performances lorsque l’application est compatible avec un cycle de vie long. Il faut alors surveiller les fuites de mémoire, éviter l’état global et réinitialiser correctement les services applicatifs qui conservent des données temporaires.

Conclusion

FrankenPHP simplifie fortement la pile technique d’une application Symfony : un seul serveur gère HTTP, Caddy et PHP, sans PHP-FPM. Le mode classique permet de démarrer sans adaptation, tandis que le mode Worker exploite la persistance de Symfony en mémoire pour augmenter le débit.

Le test le plus utile n’est pas un benchmark artificiel isolé, mais une comparaison avec les vrais contrôleurs, requêtes SQL et appels externes de l’application. C’est exactement là que l’on peut décider si le gain justifie les précautions supplémentaires du mode Worker.

Documentation utile : Symfony avec FrankenPHP et mode Worker.