PHP-FPM vs Laravel Octane — what really changes
PHP-FPM follows the "shared nothing" model: every request loads the framework from scratch, executes the code, and frees all memory. It is robust by design — a leak in one request dies with it. Octane breaks this contract. The application is bootstrapped once, then requests flow through the same process. The performance gain is massive, but developer responsibility increases.
Six structural differences to understand
- Shared lifecycle — Laravel's IoC container, providers and singletons are instantiated once and persist across all requests of a worker. A value written to a singleton in request N is still there in request N+1.
- Documented performance gain — official Laravel Octane documentation benchmarks show 4× to 6× more requests/second versus PHP-FPM 8.3 on typical endpoints, by eliminating PHP bootstrap on every request.
- Static variables = guaranteed leaks — any
staticproperty written during a request pollutes subsequent ones. Laravel Facades hide static references; some are cleaned by Octane, others are not. - Static files served by FrankenPHP — the binary includes a full HTTP server. On a VPS, no nginx in front of PHP is needed: FrankenPHP handles TLS, HTTP/2 and HTTP/3 natively, but behind Traefik it only receives plain HTTP on the internal Docker network.
- Stable RSS memory ≠ leaked memory — process memory rises at startup (framework loading) then stabilizes. A real leak shows as continuous growth over thousands of requests, not the initial level.
- Workers and concurrency — Octane starts
--workers=Nprocesses (FrankenPHP uses Zend Engine threads). Each worker keeps its own state; no shared memory between workers, but a single worker processes its requests sequentially. - Laravel 10 and 11 compatibility — Octane 2.x supports Laravel 10+. FrankenPHP as a driver is available since
laravel/octane2.3. PHP 8.2 minimum, PHP 8.3 recommended.
Prerequisites before starting
A VPS with at least 2 GB RAM (4 GB recommended for a full stack with Redis and queue workers), root access, Docker 25+ and Docker Compose v2. The application must run on Laravel 10 or 11, with laravel/octane 2.x installed (composer require laravel/octane). Traefik 3.x will manage Let's Encrypt certificates and HTTPS routing. No additional nginx is needed: FrankenPHP entirely replaces the HTTP layer.
Deploy Laravel Octane + FrankenPHP + Traefik in Docker Compose
Install laravel/octane and publish the configuration
In your Laravel project:
composer require laravel/octane php artisan octane:install --server=frankenphpThis publishes
config/octane.phpand adds environment variables to.env.example. Verify thatOCTANE_SERVER=frankenphpis set.Write the FrankenPHP Dockerfile
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"]The
--max-requests=500option restarts a worker after 500 requests: a safety net against residual memory leaks. Reduce to 100 while testing, then increase if memory remains stable.Write the complete docker-compose.yml
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: trueDeploy Traefik with automatic certificates
Create a shared Docker network then start 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 -dPrepare the production .env file
Critical variables to set on the VPS:
APP_ENV=production APP_DEBUG=false APP_KEY=base64:... # generated with php artisan key:generate DOMAIN=your-domain.com DB_DATABASE=laravel DB_USERNAME=laravel DB_PASSWORD=strong_password DB_ROOT_PASSWORD=root_strong_password OCTANE_SERVER=frankenphp CACHE_DRIVER=redis SESSION_DRIVER=redis QUEUE_CONNECTION=redisNever commit
.envto git. On the VPS, manage it withscpor a secret manager.Run migrations without downtime
Before switching to the new version, run migrations from an ephemeral container sharing the internal network:
docker compose run --rm app php artisan migrate --forceFor zero-downtime deployment, use the
--stepflag on migrations that add NOT NULL columns — first add the column as nullable, deploy the app, then add the constraint in a second migration.Start the stack and verify the healthcheck
docker compose up -d --build docker compose psWait for
dbandredisto reachhealthystatus beforeappstarts (managed bydepends_on: condition: service_healthy).Check Octane logs:
docker compose logs -f appNormal output looks like:
INFO Server running on http://0.0.0.0:8000 INFO FrankenPHP workers: 4Check status with octane:status
docker compose exec app php artisan octane:statusThis command shows the active server (frankenphp), the number of running workers and global status. A
runningresponse confirms workers are responding. If stuck,octane:reloadrestarts workers without shutting down the server.Configure hot-reload for deployments
When deploying a new version, rebuild and reload without downtime:
docker compose build app worker scheduler docker compose up -d --no-deps app worker schedulerWith the Dockerfile in place, this sequence replaces containers one by one. For Octane, the reload signal (SIGUSR1) reloads workers without dropping active connections. Docker Compose handles signal delivery automatically via
restart: unless-stopped.
Compatibility checklist before switching to production
Migrating to Octane does not require rewriting the application, but a few common patterns break silently. Go through this checklist on your code before the first deployment.
8 points to check carefully
- Singletons with mutable state — any service registered with
$this->app->singleton()that stores request-specific data (current user, locale, tenant ID) must be reset in anOctaneServiceProviderviaOctane::tick()or by implementing aRequestReceivedlistener. Audit:grep -r 'singleton' app/Providers. - Sessions — with
SESSION_DRIVER=redis, sessions correctly survive between requests. Thefiledriver can create race conditions if multiple workers write simultaneously. Migrate toredisordatabasebefore switching to Octane. - Database connections — Octane maintains persistent DB connections per worker. Configure
DB_POOL_MINandDB_POOL_MAXinconfig/database.phpif using a custom pool. Verify that MySQL acceptsmax_connections ≥ workers × replicas. - Static variables in models or services —
static $cache = []in an Eloquent model or repository persists between requests. Replace with request-scoped dependency injection, or explicitly clear in theRequestTerminatedlistener. - Non-idempotent event listeners — a listener registered in a ServiceProvider's
boot()is only registered once, but fires on every request that triggers the event. No problem by itself, unless the listener accumulates global state (counter, static array). - Jobs and queues — Jobs do not run in the main Octane process but in the separate
workercontainer. They retain the classic PHP-FPM lifecycle. No adaptations needed for Job code itself. - Uploaded files and storage —
Storage::disk('local')works normally. Watchstorage_path()in a Docker container: mount a volume for/app/storageso files persist between image rebuilds. - Third-party packages — some Laravel packages (Debugbar, Telescope) inject middlewares that accumulate data in memory. Disable them in production with
APP_ENV=productionor via conditions inconfig/debugbar.php. Check Octane compatibility on each package's repository.
FrankenPHP vs Swoole vs RoadRunner: choosing your Octane driver
Scroll the table
| Criterion | FrankenPHP | Swoole | RoadRunner |
|---|---|---|---|
| Installation | Official Docker image `dunglas/frankenphp` — no PECL extension to compile | PECL extension `swoole` — requires `pecl install swoole` or a custom image | Separate Go binary (`roadrunner`) — downloaded via Composer (`spiral/roadrunner`) |
| HTTP/2 and HTTP/3 | Native — FrankenPHP is built on Caddy which supports HTTP/3 (QUIC) out of the box | HTTP/2 supported, HTTP/3 not native | HTTP/2 via plugin, HTTP/3 not natively supported |
| PHP extension compatibility | Very broad — based on official PHP-FPM, most extensions install normally | Known incompatibilities with some blocking extensions (e.g. ext-curl in certain modes) | Broad, but some complex PECL extensions may need adaptations |
| Official Laravel recommendation | Recommended driver since Laravel 11 (2024), listed first in the Octane documentation | First historical driver, still supported but less prominent | Supported, alternative to Swoole, common in Symfony/Spiral ecosystems |
| Ops complexity | Low — single container, minimal Dockerfile, no separate daemon | Medium — the swoole extension must be compiled with the right PHP version | Medium — the RoadRunner binary must be synchronized with the Composer package version |
Monitor Octane in production with octane:status
The php artisan octane:status command queries the FrankenPHP server in real time and shows the number of active workers, workers currently handling a request, and global status (running / stopped). Integrate it in your monitoring:
# Simple check in a health-check script
docker compose exec -T app php artisan octane:status | grep -q 'running' && echo OK || echo FAILTo reload workers without dropping traffic (after a git pull without image rebuild):
docker compose exec app php artisan octane:reloadTrack the RSS memory of the main process over time: continuous growth after thousands of requests signals a leak. The docker stats app command gives real-time memory; cross with --max-requests=N to force preventive recycling.
Troubleshooting: common production errors
Progressive memory leak: the app container memory grows without ever decreasing. Likely cause: a singleton accumulates data per request. Audit with php artisan octane:status over several thousand requests. Reduce --max-requests to 100 while identifying the source with Laravel Telescope (in staging) or a profiler like Blackfire.
502 Bad Gateway Traefik: Traefik can no longer reach the app container on port 8000. Check the container is on the traefik network (docker inspect app | jq '.[0].NetworkSettings.Networks'), that the label traefik.http.services.app.loadbalancer.server.port=8000 matches the exposed port, and the container is healthy (docker compose ps). A 502 at startup is often a missing depends_on toward the db container.
Worker restarting in a loop: Octane kills and recreates a worker if it stops responding within the configured timeout. Look for FATAL in docker compose logs app. Most common cause: an unexecuted migration that raises a QueryException during bootstrap. Run docker compose run --rm app php artisan migrate --force before starting the stack.
Sessions lost between requests: SESSION_DRIVER=file with multiple workers can cause reads on a file being written. Switch to SESSION_DRIVER=redis — the redis container is already in the docker-compose.yml above.
Wrong timezone or locale: in an Alpine container, the default timezone is UTC. Add APP_TIMEZONE=Europe/Paris (or your timezone) in .env and RUN apk add --no-cache tzdata in the Dockerfile if system extensions read /etc/localtime.
Going further
You now have a working Laravel Octane stack on VPS. The following articles cover the natural next steps: [Deploy Laravel on a VPS](/blog/deployer-laravel-vps) covers the base setup without Octane. [Deploy with Traefik](/blog/deployer-avec-traefik) deepens reverse proxy configuration (middlewares, rate-limiting, secured dashboard). [Docker Compose production checklist](/blog/docker-compose-production-checklist) lists 15 points to check before considering a Compose production-ready. [Host Redis on VPS](/blog/heberger-redis-vps) details Redis persistence, authentication and memory limits. [PHP migration checklist](/blog/php-82-fin-de-vie-checklist-migration) is required reading if your application still runs on PHP 8.2.