Tutoriel

Laravel Octane sur VPS : FrankenPHP, Docker et Traefik

Développement11 min de lecture9 étapes

PHP-FPM démarre un processus par requête : efficace pour les petites apps, mais coûteux dès que le trafic monte. Laravel Octane change de paradigme — l'application est chargée une seule fois en mémoire et répond à des milliers de requêtes sans se réinitialiser. Le gain est réel : les benchmarks officiels Laravel montrent des multiplicateurs de 4× à 6× sur les requêtes/seconde par rapport à PHP-FPM 8.3. FrankenPHP, driver Octane recommandé depuis Laravel 11, intègre le serveur HTTP directement dans le binaire PHP — plus besoin de nginx en frontal. Sur un VPS avec accès root, Docker Compose et Traefik, la migration est propre et reproductible.

Sommaire· PHP-FPM vs Laravel Octane — ce qui change vraiment1/10
  1. 01PHP-FPM vs Laravel Octane — ce qui change vraiment
  2. 02Les six différences structurelles à connaître
  3. 03Prérequis avant de démarrer
  4. 04Déployer Laravel Octane + FrankenPHP + Traefik en Docker Compose
  5. 05Checklist de compatibilité avant de basculer en production
  6. 068 points à vérifier impérativement
  7. 07FrankenPHP vs Swoole vs RoadRunner : choisir son driver Octane
  8. 08Surveiller Octane en production avec octane:status
  9. 09Dépannage : erreurs fréquentes en production
  10. 10Pour aller plus loin

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=N processus (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/octane 2.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

  1. Installer laravel/octane et publier la configuration

    Dans votre projet Laravel :

    composer require laravel/octane
    php artisan octane:install --server=frankenphp

    Cela publie config/octane.php et ajoute les variables d'environnement dans .env.example. Vérifiez que OCTANE_SERVER=frankenphp est bien défini.

  2. É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=500 redé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.

  3. É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: true
  4. Déployer Traefik avec certificats automatiques

    Créez un réseau Docker partagé puis démarrez Traefik :

    docker network create traefik

    traefik.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: false
    mkdir -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 -d
  5. Pré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=redis

    Ne committez jamais .env dans git. Sur le VPS, gérez-le avec scp ou un secret manager.

  6. 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 --force

    Pour un déploiement zero-downtime, utilisez le flag --step sur 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.

  7. Démarrer la stack et vérifier le healthcheck

    docker compose up -d --build
    docker compose ps

    Attendez que db et redis passent au statut healthy avant qu'app démarre (géré par depends_on: condition: service_healthy).

    Vérifiez les logs Octane :

    docker compose logs -f app

    La sortie normale ressemble à :

    INFO  Server running on http://0.0.0.0:8000
    INFO  FrankenPHP workers: 4
  8. Vérifier l'état avec octane:status

    docker compose exec app php artisan octane:status

    Cette commande affiche le serveur actif (frankenphp), le nombre de workers en cours d'exécution et le statut global. Un retour running confirme que les workers répondent. En cas de blocage, octane:reload redémarre les workers sans couper le serveur.

  9. 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 scheduler

    Dockerfile 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 un OctaneServiceProvider via Octane::tick() ou en implémentant un listener RequestReceived. Audit : grep -r 'singleton' app/Providers.
  • Sessions — avec SESSION_DRIVER=redis, les sessions survivent correctement entre requêtes. Le driver file peut créer des race conditions si plusieurs workers écrivent simultanément. Migration vers redis ou database recommandée avant le passage à Octane.
  • Connexions base de données — Octane maintient des connexions DB persistantes par worker. Configurez DB_POOL_MIN et DB_POOL_MAX dans config/database.php si vous utilisez un pool custom. Vérifiez que MySQL accepte max_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 listener RequestTerminated.
  • 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 worker sé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 chemin storage_path() dans un conteneur Docker : montez un volume pour /app/storage afin 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=production ou via la condition dans config/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èreFrankenPHPSwooleRoadRunner
InstallationImage Docker officielle `dunglas/frankenphp` — zéro extension PECL à compilerExtension PECL `swoole` — nécessite `pecl install swoole` ou une image customBinaire Go séparé (`roadrunner`) — téléchargé via Composer (`spiral/roadrunner`)
HTTP/2 et HTTP/3Natif — FrankenPHP est construit sur Caddy qui supporte HTTP/3 (QUIC) d'officeHTTP/2 supporté, HTTP/3 non natifHTTP/2 via plugin, HTTP/3 non supporté nativement
Compatibilité extensions PHPTrès large — basé sur PHP-FPM officiel, la majorité des extensions s'installent normalementIncompatibilité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 LaravelDriver recommandé depuis Laravel 11 (2024), décrit en premier dans la documentation OctanePremier driver historique, toujours supporté mais moins mis en avantSupporté, alternatif à Swoole, courant dans les écosystèmes Symfony/Spiral
Complexité opsFaible — un seul conteneur, Dockerfile minimal, pas de démon séparéMoyenne — l'extension swoole doit être compilée avec la bonne version PHPMoyenne — 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 FAIL

Pour recharger les workers sans couper le trafic (après un git pull sans rebuild d'image) :

docker compose exec app php artisan octane:reload

Suivez 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.

Déployez votre infrastructure dès aujourd'hui

VPS Cloud. Environnements préconfigurés disponibles.

Besoin d'aide ?

Parcourez notre centre d'aide et notre FAQ, ou contactez notre équipe — rappel, WhatsApp ou e-mail. Support en français, anglais et arabe.

Écrire sur WhatsApps'ouvre dans un nouvel onglet