PHP-FPM vs Laravel Octane — ce qui change vraiment
PHP-FPM suit le modèle "shared nothing" : chaque requête charge le framework depuis zéro, exécute le code, libère toute la mémoire. C'est robuste par construction — une fuite dans une requête meurt avec elle. Octane rompt ce contrat. L'application est bootstrappée une fois, puis les requêtes s'enchaînent dans le même processus. Le gain de performance est massif, mais la responsabilité du développeur augmente.
Les six différences structurelles à connaître
- Cycle de vie partagé — le conteneur IoC de Laravel, les providers et les singletons sont instanciés une fois et persistent entre toutes les requêtes d'un worker. Une valeur écrite dans un singleton en requête N est encore là en requête N+1.
- Gain de performance documenté — les benchmarks de la documentation officielle Laravel Octane indiquent 4× à 6× plus de requêtes/seconde face à PHP-FPM 8.3 sur des endpoints typiques, grâce à l'élimination du bootstrap PHP à chaque requête.
- Variables statiques = fuites garanties — toute propriété
staticécrite pendant une requête pollue les suivantes. Les Facades Laravel cachent des références statiques ; certaines sont nettoyées par Octane, d'autres non. - Fichiers statiques servis par FrankenPHP — le binaire intègre un serveur HTTP complet. Sur un VPS, plus besoin de nginx devant PHP : FrankenPHP gère TLS, HTTP/2 et HTTP/3 nativement, mais derrière Traefik on lui délègue uniquement HTTP en clair sur le réseau Docker interne.
- Mémoire RSS stable ≠ mémoire fuitée — la mémoire du process monte au démarrage (chargement du framework) puis se stabilise. Une vraie fuite se détecte par une croissance continue sur des milliers de requêtes, pas par le niveau initial.
- Workers et concurrence — Octane démarre
--workers=Nprocessus (FrankenPHP utilise des threads Zend Engine). Chaque worker garde son propre état ; pas de partage mémoire entre workers, mais un même worker traite ses requêtes séquentiellement. - Compatibilité Laravel 10 et 11 — Octane 2.x supporte Laravel 10+. FrankenPHP comme driver est disponible depuis
laravel/octane2.3. PHP 8.2 minimum, PHP 8.3 recommandé.
Prérequis avant de démarrer
Un VPS avec 2 Go de RAM minimum (4 Go recommandés pour un stack complet avec Redis et queue workers), accès root, Docker 25+ et Docker Compose v2. L'application doit tourner sur Laravel 10 ou 11, avec laravel/octane 2.x installé (composer require laravel/octane). Traefik 3.x gérera les certificats Let's Encrypt et le routage HTTPS. Aucun nginx supplémentaire n'est nécessaire : FrankenPHP remplace entièrement la couche HTTP.
Déployer Laravel Octane + FrankenPHP + Traefik en Docker Compose
Installer laravel/octane et publier la configuration
Dans votre projet Laravel :
composer require laravel/octane php artisan octane:install --server=frankenphpCela publie
config/octane.phpet ajoute les variables d'environnement dans.env.example. Vérifiez queOCTANE_SERVER=frankenphpest bien défini.Écrire le Dockerfile FrankenPHP
FROM dunglas/frankenphp:1-php8.3-alpine RUN install-php-extensions \ pdo_mysql \ redis \ opcache \ pcntl \ zip \ intl WORKDIR /app COPY --chown=www-data:www-data . . RUN composer install --no-dev --optimize-autoloader --no-interaction \ && php artisan config:cache \ && php artisan route:cache \ && php artisan view:cache EXPOSE 8000 CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000", "--workers=4", "--max-requests=500"]L'option
--max-requests=500redémarre le worker après 500 requêtes : un filet de sécurité contre les fuites mémoire résiduelles. Réduisez à 100 en rodage, puis augmentez si la mémoire reste stable.Écrire le docker-compose.yml complet
services: app: build: . restart: unless-stopped environment: APP_ENV: production APP_KEY: ${APP_KEY} DB_CONNECTION: mysql DB_HOST: db DB_DATABASE: ${DB_DATABASE} DB_USERNAME: ${DB_USERNAME} DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis CACHE_DRIVER: redis SESSION_DRIVER: redis QUEUE_CONNECTION: redis OCTANE_SERVER: frankenphp networks: - internal - traefik labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`${DOMAIN}`)" - "traefik.http.routers.app.entrypoints=websecure" - "traefik.http.routers.app.tls.certresolver=letsencrypt" - "traefik.http.services.app.loadbalancer.server.port=8000" depends_on: db: condition: service_healthy redis: condition: service_healthy worker: build: . restart: unless-stopped command: ["php", "artisan", "queue:work", "redis", "--sleep=3", "--tries=3", "--max-time=3600"] environment: APP_ENV: production APP_KEY: ${APP_KEY} DB_HOST: db DB_DATABASE: ${DB_DATABASE} DB_USERNAME: ${DB_USERNAME} DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis QUEUE_CONNECTION: redis networks: - internal depends_on: db: condition: service_healthy redis: condition: service_healthy scheduler: build: . restart: unless-stopped command: ["sh", "-c", "while true; do php artisan schedule:run >> /dev/null 2>&1; sleep 60; done"] environment: APP_ENV: production APP_KEY: ${APP_KEY} DB_HOST: db DB_DATABASE: ${DB_DATABASE} DB_USERNAME: ${DB_USERNAME} DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis networks: - internal depends_on: db: condition: service_healthy db: image: mysql:8.0 restart: unless-stopped environment: MYSQL_DATABASE: ${DB_DATABASE} MYSQL_USER: ${DB_USERNAME} MYSQL_PASSWORD: ${DB_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} volumes: - db_data:/var/lib/mysql networks: - internal healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine restart: unless-stopped volumes: - redis_data:/data networks: - internal healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 volumes: db_data: redis_data: networks: internal: traefik: external: trueDéployer Traefik avec certificats automatiques
Créez un réseau Docker partagé puis démarrez Traefik :
docker network create traefiktraefik.yml:entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https websecure: address: ":443" certificatesResolvers: letsencrypt: acme: email: [email protected] storage: /letsencrypt/acme.json httpChallenge: entryPoint: web providers: docker: exposedByDefault: false network: traefik api: dashboard: falsemkdir -p /opt/traefik/letsencrypt touch /opt/traefik/letsencrypt/acme.json chmod 600 /opt/traefik/letsencrypt/acme.json docker compose -f /opt/traefik/docker-compose.yml up -dPréparer le fichier .env de production
Variables critiques à définir sur le VPS :
APP_ENV=production APP_DEBUG=false APP_KEY=base64:... # généré avec php artisan key:generate DOMAIN=votre-domaine.com DB_DATABASE=laravel DB_USERNAME=laravel DB_PASSWORD=mot_de_passe_fort DB_ROOT_PASSWORD=root_mot_de_passe OCTANE_SERVER=frankenphp CACHE_DRIVER=redis SESSION_DRIVER=redis QUEUE_CONNECTION=redisNe committez jamais
.envdans git. Sur le VPS, gérez-le avecscpou un secret manager.Exécuter les migrations sans interruption
Avant de basculer vers la nouvelle version, exécutez les migrations depuis un conteneur éphémère qui partage le réseau interne :
docker compose run --rm app php artisan migrate --forcePour un déploiement zero-downtime, utilisez le flag
--stepsur les migrations qui ajoutent des colonnes NOT NULL — ajoutez d'abord la colonne nullable, déployez l'app, puis ajoutez la contrainte dans une seconde migration.Démarrer la stack et vérifier le healthcheck
docker compose up -d --build docker compose psAttendez que
dbetredispassent au statuthealthyavant qu'appdémarre (géré pardepends_on: condition: service_healthy).Vérifiez les logs Octane :
docker compose logs -f appLa sortie normale ressemble à :
INFO Server running on http://0.0.0.0:8000 INFO FrankenPHP workers: 4Vérifier l'état avec octane:status
docker compose exec app php artisan octane:statusCette commande affiche le serveur actif (frankenphp), le nombre de workers en cours d'exécution et le statut global. Un retour
runningconfirme que les workers répondent. En cas de blocage,octane:reloadredémarre les workers sans couper le serveur.Configurer le rechargement à chaud pour les déploiements
Lors d'un nouveau déploiement, reconstruisez l'image et rechargez sans downtime :
docker compose build app worker scheduler docker compose up -d --no-deps app worker schedulerDockerfile en place, cette séquence remplace les conteneurs un par un. Pour Octane, le signal de rechargement (SIGUSR1) recharge les workers sans fermer les connexions actives. Docker Compose gère l'envoi du signal automatiquement via
restart: unless-stopped.
Checklist de compatibilité avant de basculer en production
La migration vers Octane ne nécessite pas de réécrire l'application, mais quelques patterns courants cassent silencieusement. Parcourez cette checklist sur votre code avant le premier déploiement.
8 points à vérifier impérativement
- Singletons avec état mutable — tout service enregistré avec
$this->app->singleton()qui stocke des données spécifiques à une requête (utilisateur courant, locale, tenant ID) doit être réinitialisé dans unOctaneServiceProviderviaOctane::tick()ou en implémentant un listenerRequestReceived. Audit :grep -r 'singleton' app/Providers. - Sessions — avec
SESSION_DRIVER=redis, les sessions survivent correctement entre requêtes. Le driverfilepeut créer des race conditions si plusieurs workers écrivent simultanément. Migration versredisoudatabaserecommandée avant le passage à Octane. - Connexions base de données — Octane maintient des connexions DB persistantes par worker. Configurez
DB_POOL_MINetDB_POOL_MAXdansconfig/database.phpsi vous utilisez un pool custom. Vérifiez que MySQL acceptemax_connections ≥ workers × replicas. - Variables statiques dans les modèles ou services —
static $cache = []dans un Eloquent model ou un repository persiste entre requêtes. Remplacez par une injection de dépendance avec scope requête, ou videz explicitement dans le listenerRequestTerminated. - Event listeners non idempotents — un listener enregistré dans
boot()d'un ServiceProvider n'est enregistré qu'une fois, mais s'exécute à chaque requête qui déclenche l'événement. Pas de problème en soi, sauf si le listener accumule un état global (compteur, tableau statique). - Jobs et queues — les Jobs ne tournent pas dans le process Octane principal mais dans le conteneur
workerséparé. Ils gardent le cycle PHP-FPM classique. Aucune adaptation requise pour le code des Jobs eux-mêmes. - Fichiers uploadés et stockage —
Storage::disk('local')fonctionne normalement. Attention au cheminstorage_path()dans un conteneur Docker : montez un volume pour/app/storageafin que les fichiers persistent entre les rebuilds d'image. - Packages tiers — certains packages Laravel (Debugbar, Telescope) injectent des middlwares qui accumulent des données en mémoire. Désactivez-les en production avec
APP_ENV=productionou via la condition dansconfig/debugbar.php. Vérifiez la compatibilité Octane sur le dépôt de chaque package.
FrankenPHP vs Swoole vs RoadRunner : choisir son driver Octane
Faites défiler le tableau
| Critère | FrankenPHP | Swoole | RoadRunner |
|---|---|---|---|
| Installation | Image Docker officielle `dunglas/frankenphp` — zéro extension PECL à compiler | Extension PECL `swoole` — nécessite `pecl install swoole` ou une image custom | Binaire Go séparé (`roadrunner`) — téléchargé via Composer (`spiral/roadrunner`) |
| HTTP/2 et HTTP/3 | Natif — FrankenPHP est construit sur Caddy qui supporte HTTP/3 (QUIC) d'office | HTTP/2 supporté, HTTP/3 non natif | HTTP/2 via plugin, HTTP/3 non supporté nativement |
| Compatibilité extensions PHP | Très large — basé sur PHP-FPM officiel, la majorité des extensions s'installent normalement | Incompatibilités connues avec certaines extensions bloquantes (ex. ext-curl dans certains modes) | Large, mais certaines extensions PECL complexes peuvent nécessiter des adaptations |
| Recommandation officielle Laravel | Driver recommandé depuis Laravel 11 (2024), décrit en premier dans la documentation Octane | Premier driver historique, toujours supporté mais moins mis en avant | Supporté, alternatif à Swoole, courant dans les écosystèmes Symfony/Spiral |
| Complexité ops | Faible — un seul conteneur, Dockerfile minimal, pas de démon séparé | Moyenne — l'extension swoole doit être compilée avec la bonne version PHP | Moyenne — le binaire RoadRunner doit être synchronisé avec la version du package Composer |
Surveiller Octane en production avec octane:status
La commande php artisan octane:status interroge le serveur FrankenPHP en temps réel et indique le nombre de workers actifs, les workers en cours de traitement d'une requête et l'état global (running / stopped). Intégrez-la dans votre monitoring :
# Vérification simple dans un script de health-check
docker compose exec -T app php artisan octane:status | grep -q 'running' && echo OK || echo FAILPour recharger les workers sans couper le trafic (après un git pull sans rebuild d'image) :
docker compose exec app php artisan octane:reloadSuivez la mémoire RSS du process principal sur la durée : une augmentation continue après des milliers de requêtes signale une fuite. La commande docker stats app donne la mémoire en temps réel ; croisez avec --max-requests=N pour forcer le recyclage préventif.
Dépannage : erreurs fréquentes en production
Fuite mémoire progressive : la mémoire du conteneur app croît sans jamais redescendre. Cause probable : un singleton accumule des données par requête. Audit avec php artisan octane:status sur plusieurs milliers de requêtes. Réduisez --max-requests à 100 en attendant d'identifier la source avec Laravel Telescope (en staging) ou un profiler comme Blackfire.
502 Bad Gateway Traefik : Traefik ne peut plus joindre le conteneur app sur le port 8000. Vérifiez que le conteneur est sur le réseau traefik (docker inspect app | jq '.[0].NetworkSettings.Networks'), que le label traefik.http.services.app.loadbalancer.server.port=8000 correspond bien au port exposé, et que le conteneur est healthy (docker compose ps). Un 502 au démarrage est souvent un depends_on manquant vers le conteneur db.
Worker qui redémarre en boucle : Octane tue et recrée un worker si celui-ci ne répond plus dans le timeout configuré. Cherchez un FATAL dans docker compose logs app. Cause la plus fréquente : une migration non exécutée qui lève une QueryException dès le bootstrap. Lancez docker compose run --rm app php artisan migrate --force avant de démarrer la stack.
Sessions perdues entre requêtes : SESSION_DRIVER=file avec plusieurs workers peut provoquer des lectures sur un fichier en cours d'écriture. Basculez sur SESSION_DRIVER=redis — le conteneur redis est déjà dans le docker-compose.yml ci-dessus.
Timezone ou locale incorrecte : dans un conteneur Alpine, la timezone par défaut est UTC. Ajoutez APP_TIMEZONE=Africa/Casablanca (ou votre timezone) dans .env et RUN apk add --no-cache tzdata dans le Dockerfile si des extensions système lisent /etc/localtime.
Pour aller plus loin
Vous avez maintenant une stack Laravel Octane fonctionnelle sur VPS. Les articles suivants couvrent les étapes naturelles autour de ce déploiement : [Déployer Laravel sur un VPS](/blog/deployer-laravel-vps) décrit la configuration de base sans Octane, utile pour comprendre les différences. [Déployer avec Traefik](/blog/deployer-avec-traefik) approfondit la configuration du reverse proxy (middlewares, rate-limiting, dashboard sécurisé). [Docker Compose production checklist](/blog/docker-compose-production-checklist) liste les 15 points à vérifier avant de considérer un Compose production-ready. [Héberger Redis sur VPS](/blog/heberger-redis-vps) détaille la persistance, l'authentification et les limites mémoire de Redis en production. [Checklist de migration PHP](/blog/php-82-fin-de-vie-checklist-migration) est à lire si votre application tourne encore sur PHP 8.2.