Laravel

Octane

Laravel Octane boots your application once and keeps it in memory between requests. Run it on the frankenphp variation, which keeps the image's security headers, trusted proxies, SSL modes, and health check. Use Swoole on the cli image only when you need a Swoole-only feature.

Choose an Octane server

ServerHow to run itWhat you get
FrankenPHPBuilt into the frankenphp variationNothing to install. The image's Caddyfile and every CADDY_* variable work.
Swoole or Open Swoolecli plus the extensionFour Octane features FrankenPHP lacks. You add the HTTP hardening yourself.
RoadRunnercli plus the rr binaryA Go server with its own .rr.yaml. You add the HTTP hardening yourself.
Octane replaces PHP-FPM, so it can't run in the fpm, fpm-nginx, or fpm-apache variations.

Run Octane on FrankenPHP

These steps assume a Laravel application in the current directory. To create a new one with Docker, see Spin.

Point the service at Octane

compose.yml
services:
  php:
    image: serversideup/php:8.5-frankenphp
    ports:
      - "80:8080"
    volumes:
      - .:/var/www/html/
    command: ["php", "artisan", "octane:start", "--server=frankenphp", "--port=8080", "--caddyfile=/etc/frankenphp/Caddyfile"]
  • --server=frankenphp starts FrankenPHP in worker mode through Octane.
  • --port=8080 must match CADDY_HTTP_PORT (default 8080). The exec form (the JSON array) doesn't expand variables, so write the number.
  • --caddyfile=/etc/frankenphp/Caddyfile uses the image's Caddyfile. Without it, Octane uses its own minimal Caddyfile, and you lose trusted proxies, security headers, asset caching, SSL modes, the health check endpoint, and most CADDY_* variables.

Octane sets LARAVEL_OCTANE=1 when it starts FrankenPHP. The Caddyfile then loads frankenphp-worker.php as a worker and routes every request to it instead of index.php. It also turns on the Caddy admin API on localhost, which octane:status, octane:reload, and octane:stop need. Everything else matches classic mode.

Install Octane

Terminal
docker compose run --rm php composer require laravel/octane
docker compose run --rm php php artisan octane:install --server=frankenphp

The install command publishes config/octane.php, sets OCTANE_SERVER=frankenphp in .env, and creates public/frankenphp-worker.php, the script FrankenPHP keeps in memory.

It also adds frankenphp-worker.php to .gitignore. Remove that line and commit the file. Every image you build then contains it. Otherwise octane:start has to recreate it at startup, which only works if public is writable.

Start the application

Terminal
docker compose up

Your application answers at http://localhost, served by Octane.

Logging

Octane relays only FrankenPHP's stderr and only parses JSON. The defaults satisfy both: CADDY_LOG_OUTPUT is stderr, and with CADDY_LOG_FORMAT=auto, Caddy writes JSON when stderr is not a terminal, which is always true under Octane.

Leave CADDY_LOG_OUTPUT and CADDY_LOG_FORMAT at their defaults. With CADDY_LOG_OUTPUT=stdout, Octane discards your logs. With CADDY_LOG_FORMAT=console, Octane can't parse the lines and wraps each one, warnings and errors included, in an INFO message.

Octane also sets the log level. It sets CADDY_SERVER_LOG_LEVEL to INFO when APP_ENV is local and to WARN everywhere else, which overrides LOG_OUTPUT_LEVEL. Caddy logs requests at INFO. What you see depends on the environment:

  • In local, Octane prints one line per request with the method, path, status, and duration.
  • Everywhere else, it prints only Caddy's warnings and errors.
  • With --log-level=INFO on octane:start, it passes Caddy's JSON through untouched: one object per request, ready for a log collector.

PHP settings still apply

FrankenPHP reads the same php.ini files from /usr/local/etc/php/conf.d/ in every mode. All PHP_* variables and any .ini files you mount there work as they do in classic mode. Set PHP_OPCACHE_ENABLE=1 in production; see Production performance tuning.

Octane options

Octane passes its octane:start options to FrankenPHP as environment variables. With the image's Caddyfile:

OptionBehavior
--portMatch CADDY_HTTP_PORT, which is the port Caddy listens on. Octane also derives the admin port from it: --port=8080 puts the admin API on 2099.
--hostNot used. The container listens on all interfaces.
--workersSets the number of PHP threads for the worker. Defaults to twice the number of CPUs.
--max-requestsRestarts a worker after this many requests (default 500) to limit memory leaks.
--watchUses FrankenPHP's file watcher, built into the image, with the watch paths from config/octane.php. No Node or Chokidar needed. --poll has no effect.
--admin-portUsed by octane:status, octane:reload, and octane:stop. CADDY_ADMIN is ignored. The admin API listens on localhost only.
--log-levelSets CADDY_SERVER_LOG_LEVEL. See Logging.
--https, --http-redirectNot used. Configure HTTPS with SSL_MODE and CADDY_AUTO_HTTPS, as in classic mode.
Keep the admin API on localhost, even though the hidden octane:frankenphp command accepts --admin-host. The Caddy admin API has no authentication and can reconfigure the web server. The image lists port 2019 in EXPOSE, so with --admin-host=0.0.0.0 --admin-port=2019, docker run -P publishes it to the host.

The max_execution_time setting in config/octane.php works as Laravel documents, because Octane passes it to the worker script.

Variables Octane replaces

Octane sets these for its own Caddyfile. Any value you set is replaced while Octane runs:

  • CADDY_SERVER_EXTRA_DIRECTIVES carries the mercure array from config/octane.php. Put your own directives in a .caddyfile mounted into /etc/frankenphp/caddyfile-server.d/.
  • CADDY_GLOBAL_OPTIONS is ignored under Octane, because Octane sets it to auto_https disable_redirects, which would override CADDY_AUTO_HTTPS. Put global options in /etc/frankenphp/caddyfile-global.d/.
  • CADDY_SERVER_LOGGER isn't read, because Caddy already writes JSON under Octane.

See Adding your own Caddyfile rules for the folder layout.

Octane 2.14 and newer let you override any variable it sets through caddy.env in config/octane.php. This keeps request logs on in every environment without --log-level:
config/octane.php
'caddy' => [
    'env' => [
        'CADDY_SERVER_LOG_LEVEL' => 'INFO',
    ],
],
See the Octane 2.14.0 release notes.

The Caddyfile loads frankenphp-worker.php from CADDY_SERVER_ROOT, not from the APP_PUBLIC_PATH that Octane sets. If you changed APP_BASE_DIR, set CADDY_SERVER_ROOT to match, as in classic mode.

Mercure

Turn on FrankenPHP's Mercure hub with the MERCURE_* variables, as in classic mode. Leave the mercure array out of config/octane.php.

FrankenPHP's Laravel docs use that array instead. Octane passes it in CADDY_SERVER_EXTRA_DIRECTIVES, which lands in every site block. With SSL_MODE=mixed or full there is more than one site block, which means more than one unnamed hub. Mercure 1.0 refuses to start in that case. The MERCURE_* variables name the hub, and every site block shares it.

Run Octane on Swoole or RoadRunner

Run these servers from the cli image, which has no web server, so the HTTP protections are yours to add.

Swoole-only Octane features

Swoole and Open Swoole support four Octane features that FrankenPHP and RoadRunner don't:

  • Concurrent tasks with Octane::concurrently(), run by Swoole's task workers. Set how many with --task-workers on octane:start.
  • Ticks and intervals that run a callback every N seconds.
  • The Octane cache driver, stored in shared memory and flushed when the server restarts.
  • Tables that share structured data between workers, also flushed on restart.

What the cli image does and doesn't give you

The cli image has what Octane needs on the PHP side: the pcntl extension, Composer, install-php-extensions, the startup scripts (including Laravel automations), the PHP_* variables, and the unprivileged www-data user.

It has no web server. In the frankenphp variation, the image's Caddyfile handles the HTTP layer. Swoole's HTTP server is the PHP process itself, with no place for caching rules or path blocking. RoadRunner has a config file that nothing in the image fills in. These protections move to you:

Protectionfrankenphpcli + Swoole or RoadRunnerWhere to add it
Blocking .env, .git/, *.bak, *.log, *.sql, *.ini, and dotfiles✅❌Reverse proxy
Cache-Control: immutable on static assets✅❌Reverse proxy
Access-Control-Allow-Origin on fonts✅❌Reverse proxy
X-Frame-Options, X-Content-Type-Options, Referrer-Policy✅❌Laravel middleware or reverse proxy
Removing the Server and X-Powered-By headers✅❌Reverse proxy
Trusted proxies through TRUSTED_PROXY✅❌Laravel's TrustProxies middleware
SSL_MODE, automatic HTTPS, and ACME certificates✅❌Reverse proxy
The /healthcheck endpoint and HEALTHCHECK_PATH✅❌healthcheck-octane or a Laravel route
Access logs with the authorization parameter redacted✅❌Reverse proxy
Both servers expose files from your public directory that the Caddyfile would block with a 403:
  • Swoole serves every non-PHP file in public as it is, including a stray .env, .git directory, database dump, or *.bak. It refuses .php files, so an uploaded PHP file returns 404 instead of running.
  • RoadRunner, as Octane starts it, serves everything in public, including .php files as plain-text source. See Lock down RoadRunner's static files.
Put a reverse proxy in front of Octane in production.

Run Octane on Swoole

Swoole is a PHP extension. Install it in your own Dockerfile. For Open Swoole, install openswoole instead; everything else is the same.

Dockerfile
FROM serversideup/php:8.5-cli

USER root
RUN install-php-extensions swoole
USER www-data

Install Octane and point it at Swoole:

Terminal
docker compose run --rm php composer require laravel/octane
docker compose run --rm php php artisan octane:install --server=swoole

Start it from your compose file:

compose.yml
services:
  php:
    build:
      context: .
    ports:
      - "80:8000"
    volumes:
      - .:/var/www/html/
    command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
    healthcheck:
      test: ["CMD", "healthcheck-octane"]
      start_period: 10s

Octane binds to 127.0.0.1 by default, which Docker's published port can't reach. Always pass --host=0.0.0.0 in a container. Port 8000 is Octane's default. It is above 1024, which lets www-data bind it.

Run Octane on RoadRunner

RoadRunner needs the rr binary and the sockets extension, which the cli image doesn't include:

Dockerfile
FROM serversideup/php:8.5-cli

USER root
RUN install-php-extensions sockets

# TARGETARCH is set by BuildKit, so this works on amd64 and arm64
ARG TARGETARCH
ARG ROADRUNNER_VERSION=2025.1.15
RUN docker-php-serversideup-download \
      "https://github.com/roadrunner-server/roadrunner/releases/download/v${ROADRUNNER_VERSION}/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}.tar.gz" \
      /tmp/roadrunner.tar.gz && \
    tar -xzf /tmp/roadrunner.tar.gz -C /tmp && \
    mv "/tmp/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}/rr" /usr/local/bin/rr && \
    chmod +x /usr/local/bin/rr && \
    rm -rf /tmp/roadrunner*

USER www-data
Laravel's docs download the binary into your project directory with spiral/roadrunner-cli and ./vendor/bin/rr get-binary. On Laravel 13 that fails: spiral/roadrunner-cli requires symfony/console 7 or older, Laravel 13 ships version 8, and php artisan octane:install --server=roadrunner calls ./vendor/bin/rr. Installing the binary in the image avoids both, pins the version, and matches each image's architecture. Skip octane:install and set OCTANE_SERVER=roadrunner in .env.

Add the PHP side, then start the server:

Terminal
docker compose run --rm php composer require laravel/octane spiral/roadrunner-http
compose.yml
services:
  php:
    build:
      context: .
    ports:
      - "80:8000"
    volumes:
      - .:/var/www/html/
    command: ["php", "artisan", "octane:start", "--server=roadrunner", "--host=0.0.0.0", "--port=8000", "--rpc-port=6001", "--rr-config=/var/www/html/.rr.yaml"]
    healthcheck:
      test: ["CMD", "healthcheck-octane"]
      start_period: 10s

Lock down RoadRunner's static files

Create .rr.yaml in your project root. --rr-config in the command above points RoadRunner at it:

.rr.yaml
version: "3"

http:
  static:
    forbid: [".php", ".env", ".bak", ".log", ".sql", ".ini", ".git"]

With this file, /.env, /uploaded.php, and /config.bak return 404 and your real assets still load. It covers less than a reverse proxy does.

Put NGINX in front for static files and TLS

Laravel recommends running Octane behind NGINX or Apache in production, to serve static assets and terminate TLS. Add the protections from the table above there too.

compose.yml
services:
  php:
    build:
      context: .
    volumes:
      - .:/var/www/html/
    command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]

  web:
    image: nginx:1.30-alpine
    ports:
      - "80:80"
    volumes:
      - .:/var/www/html/:ro
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - php

Start from Laravel's NGINX configuration for Octane and set proxy_pass to http://php:8000 instead of http://127.0.0.1:8000, because Octane runs in its own container.

Use a plain nginx image, not fpm-nginx. The NGINX in fpm-nginx passes requests to PHP-FPM over FastCGI and isn't set up to proxy to Octane.

Turn on OPcache

Octane on Swoole or RoadRunner is a CLI process, so OPcache needs both PHP_OPCACHE_ENABLE=1 and PHP_OPCACHE_ENABLE_CLI=1. The image sets PHP_OPCACHE_ENABLE_CLI=1 already, but PHP_OPCACHE_ENABLE defaults to 0, so set it in production. See Production performance tuning for the other settings.

File watching needs Node and Chokidar

Outside FrankenPHP, Octane's --watch flag needs Node and Chokidar. Neither ships in the cli image. Add Node to your development image and install Chokidar in your project:

Terminal
npm install --save-dev chokidar

Health checks

On FrankenPHP, the image's health check keeps working, because the Caddyfile still answers /healthcheck. Swoole and RoadRunner have no /healthcheck endpoint. On any server, healthcheck-octane checks Octane itself, as the Swoole and RoadRunner examples do. See Laravel health checks.

Code that stays in memory

Code that worked under PHP-FPM can misbehave once it stays in memory:

  • Injecting the request or container into long-lived objects can leak data between requests. See dependency injection and Octane.
  • Static arrays that grow on every request leak memory. See managing memory leaks.
  • New code needs octane:reload or a new container. In production, replace the container. octane:reload also works in place, because the Caddyfile enables the admin API.