Laravel Octane + FrankenPHP — Production Deployment Without Surprises

Deploy Laravel Octane with FrankenPHP behind Nginx — install the runtime, audit singletons, configure systemd workers, and validate the boot-time win.

Steven Richardson
Steven Richardson
· 9 min read

Octane's marketing is excellent. The production reality is messier — singletons hold stale containers, FPM-style env reloads stop working, and most "Hello Octane" tutorials end at php artisan octane:start on a laptop. This article is the rest of the journey: a Laravel 13 app, FrankenPHP under systemd, Nginx in front, and a load test that proves the win.

I run a few Octane workloads in production behind Nginx. The setup that follows is the one I actually deploy — no Docker, no Vapor, just a VPS, a binary, and a process supervisor.

Install Octane and the FrankenPHP runtime#

Pull Octane from Composer and let the install command pick FrankenPHP. Octane will detect that no FrankenPHP binary is present and download the right architecture for you, so you do not need to wrestle with PECL or build Swoole.

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

The installer drops the static frankenphp binary inside vendor/bin/ and writes config/octane.php. From here, php artisan octane:start --server=frankenphp will serve the app on port 8000. That is fine for smoke testing, but production needs a process supervisor and a reverse proxy in front. We will get there step by step. If you have not picked a deployment platform yet, my Laravel Vapor vs Laravel Forge breakdown covers where Octane fits in each.

Audit the codebase for stateful singletons#

The single biggest source of Octane regressions is leftover state. Octane boots the framework once and feeds requests to the same PHP process, so any class that hangs on to the container, the request, or grows a static array between requests becomes a slow-burn bug.

The most common offenders are bindings that capture the container or request in their constructor:

// app/Providers/AppServiceProvider.php

// ❌ Bad: container is captured at boot time, never refreshed.
$this->app->singleton(InvoiceService::class, function (Application $app) {
    return new InvoiceService($app['request']);
});

// ✅ Good: pass a resolver closure so each call sees the current request.
$this->app->singleton(InvoiceService::class, function () {
    return new InvoiceService(fn () => Container::getInstance()->make('request'));
});

Run a grep for $this->app->singleton, static $cache, and Service::$data[] style accumulators. Anything that takes Request, Container, or Config in a singleton constructor needs to either become a transient bind() or accept a resolver. Laravel resets first-party state for you, but it cannot fix application-level bindings.

If you are also running queue workers in long-lived mode, the same discipline applies — I covered worker-side memory in keeping Laravel queue workers from leaking memory.

Configure Octane workers and max requests#

Open config/octane.php and tune two things: worker count and max requests. Workers default to one per CPU core, which is sane for a vCPU-bound app. For an IO-heavy app, you can bump it slightly above core count. max_requests defaults to 500 and is your safety net against memory creep — Octane gracefully recycles workers after they have served that many requests.

// config/octane.php

return [
    'server' => env('OCTANE_SERVER', 'frankenphp'),
    'https' => env('OCTANE_HTTPS', true),

    'warm' => [
        ...Octane::defaultServicesToWarm(),
    ],

    'flush' => [
        // Add anything you need flushed between requests.
    ],

    'listeners' => [
        // Default listeners handle most state resets for you.
    ],

    'max_execution_time' => 30,
];

Set OCTANE_HTTPS=true in your environment so Laravel emits https:// URLs even though Nginx is the thing terminating TLS. Forgetting this produces mixed-content warnings on assets generated by url() and route().

Run FrankenPHP under a systemd unit#

Supervisor works, but systemd is already on the box and gives you cleaner journal logging, restart-on-failure, and systemctl reload for graceful worker recycles. Drop this unit at /etc/systemd/system/octane.service:

[Unit]
Description=Laravel Octane (FrankenPHP)
After=network.target

[Service]
Type=simple
User=forge
Group=forge
WorkingDirectory=/home/forge/example.com
Environment="APP_ENV=production"
ExecStart=/usr/bin/php artisan octane:start \
    --server=frankenphp \
    --host=127.0.0.1 \
    --port=8000 \
    --workers=4 \
    --max-requests=500
ExecReload=/usr/bin/php artisan octane:reload
Restart=always
RestartSec=2
KillSignal=SIGTERM
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Reload systemd and start the unit:

sudo systemctl daemon-reload
sudo systemctl enable --now octane
sudo systemctl status octane

systemctl reload octane now runs octane:reload, which restarts workers without dropping in-flight requests. That is the deploy hook you will wire into your CI/CD pipeline. If you are deploying via Forge and GitHub Actions, my zero-downtime Laravel deployment workflow shows where the reload step slots in.

Front the app with Nginx for TLS and static assets#

Octane can serve everything itself, but in production I want Nginx terminating TLS, handling static files, and giving me one consistent log format across every site on the box. The Octane docs ship a Nginx template — here is the version I run, with HTTP/2, gzip, and the WebSocket upgrade map already wired:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com;
    server_tokens off;
    root /home/forge/example.com/public;
    index index.php;
    charset utf-8;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location /index.php {
        try_files /not_exists @octane;
    }

    location / {
        try_files $uri $uri/ @octane;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location @octane {
        set $suffix "";
        if ($uri = /index.php) {
            set $suffix ?$query_string;
        }

        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header Scheme $scheme;
        proxy_set_header SERVER_PORT $server_port;
        proxy_set_header REMOTE_ADDR $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_pass http://127.0.0.1:8000$suffix;
    }
}

The try_files rules send any real file (CSS, JS, fonts, images) straight from disk through Nginx, and only fall through to Octane for application routes. Skip the static-file middleware that ships with Octane — Nginx is faster and you avoid double-handling. While we are talking about Nginx, blocking malicious 404s at the edge is a no-brainer once Octane is live; bad bots burn worker capacity faster than they burn FPM.

Run a load test to validate the boot-time gains#

Numbers beat vibes. Before flipping DNS, measure FPM versus Octane on the same hardware with wrk or k6. A no-op route is the cleanest signal because it removes database and cache noise:

# 30 second test, 4 threads, 200 concurrent connections.
wrk -t4 -c200 -d30s https://example.com/ping

On a 4 vCPU box I see FPM hover around 700-900 req/s and Octane on FrankenPHP push 4,500-6,000 req/s on the same payload. p99 latency drops from roughly 60ms to under 10ms. Your numbers will differ, but the shape is consistent — Octane wins biggest on routes where the framework boot dominates execution time.

If your real routes are not seeing that gain, the bottleneck has moved to the database or external services, not PHP. That is good news; it means you have already extracted the easy wins from PHP itself.

Set up a zero-downtime restart workflow#

The deploy script needs three things in order: pull the new code, run migrations, then signal Octane to recycle. octane:reload waits for in-flight requests to finish on each worker before swapping in the new code, so there is no dropped traffic.

# deploy.sh
set -e

cd /home/forge/example.com

git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Recycle Octane workers — graceful, zero-downtime.
sudo systemctl reload octane

The order matters. Run migrations before the reload so new workers boot against the new schema. Run config:cache before the reload so workers do not re-read .env mid-recycle. If you are stacking this on top of a multi-stage Docker build, the same principles apply — bake the code in, then trigger the reload.

Gotchas and Edge Cases#

A few sharp edges I have hit running this in production:

config:cache and Octane do not lie about their relationship. If you forget to clear the config cache after a .env change, workers will happily run with stale config until the next reload. Bake config:clear && config:cache into the deploy script every time.

Filament and Livewire memory creep. There is an open issue tracking Filament memory growth under Octane workers. Until it is fully resolved, drop --max-requests to 250 if you are running heavy admin panels and watch RSS over a few hours. The same applies to anything that builds Blade view caches at request time — the ViewServiceProvider memory leak in Octane accumulates terminating callbacks across requests.

Forge and Vapor coverage is uneven. Forge supports Octane and FrankenPHP via its provisioning UI, but advanced FrankenPHP configuration (custom Caddyfiles, worker mode tweaks) still needs manual edits. Vapor is Lambda — it cannot run Octane today. If you need Octane and managed hosting, Laravel Cloud is the path.

Singletons are not the only leak source. Watch for static properties on application classes, anything attaching listeners inside boot() without checking duplicates, and packages that call Auth::shouldUse() at boot. The Octane docs are explicit about managing memory leaks and worth re-reading once a quarter.

Do not use Octane's static file middleware behind Nginx. It is there for the case where Octane is the only server. With Nginx in front, every static request would round-trip through PHP for no gain.

Wrapping Up#

FrankenPHP gives you most of Octane's win with the least operational tax — one binary, one systemd unit, no PHP extensions to compile. The honest checklist is small: install the runtime, audit singletons, set --max-requests, put Nginx in front, and load-test before you flip DNS.

If you want to keep the production stack moving, Laravel health checks for Kubernetes probes is the natural next step for adding readiness signals once Octane is live, and the complete Laravel developer toolchain for 2026 covers the supporting tools that make this whole flow boring in the best way.

FAQ#

Is Laravel Octane with FrankenPHP production-ready?

Yes. FrankenPHP is the officially recommended Octane runtime in the Laravel docs, and the Laravel team has shipped first-party install commands, blog posts, and HTTPS support for it. Production teams are running it under systemd or Supervisor with --max-requests set, behind Nginx for TLS termination. The maturity story is much closer to FPM today than it was when Octane originally launched on Swoole.

How do I deploy FrankenPHP behind Nginx?

Run FrankenPHP on 127.0.0.1:8000 under systemd, then add an Nginx server block that terminates TLS and proxies @octane traffic to that port using proxy_pass. The official Octane Nginx template handles the WebSocket upgrade headers and the try_files fallthrough so static assets are served from disk while application routes hit Octane. The full server block in this article is the version I deploy.

What's the performance difference between FrankenPHP and PHP-FPM?

Octane on FrankenPHP boots the framework once and reuses it across requests, so per-request overhead drops from roughly 40-60ms on FPM to 4-6ms. On no-op endpoints I see throughput improve by 5-8x on the same hardware, with p99 latency falling from around 60ms to under 10ms. The gain shrinks on routes dominated by database or external API time, because the bottleneck is no longer PHP boot.

Do I need Swoole if I use FrankenPHP?

No. FrankenPHP is a standalone PHP application server written in Go and ships as a single static binary. It does not depend on Swoole or Open Swoole and does not need any PECL extensions. The trade-off is that Swoole-only Octane features (concurrent tasks, ticks, intervals, the Octane cache, Swoole tables) are not available — but for most web apps, FrankenPHP's HTTP performance is the feature you actually wanted.

How do I avoid memory leaks with Octane in production?

Three habits cover most of it. First, audit your service container for singletons that capture the request, container, or config in their constructor — refactor them to either bind transient or take a resolver closure. Second, never accumulate state in static properties between requests. Third, set --max-requests=500 (or lower for memory-heavy admin panels) so workers recycle gracefully before any unnoticed creep can compound.

Can I use FrankenPHP on Laravel Forge or Vapor?

Forge supports FrankenPHP through its provisioning UI today and exposes the standard Octane controls. Vapor cannot run Octane at all because it deploys to AWS Lambda, which is a per-request execution model that defeats Octane's whole premise. If you want managed Octane hosting, Laravel Cloud is the option that actually runs it for you end to end.

How do I run zero-downtime restarts with FrankenPHP?

Use php artisan octane:reload, or wire it through systemd as ExecReload. Octane's reload waits for each worker to finish its in-flight request before swapping in the freshly booted code, so no traffic is dropped. In a deploy script, run migrations and config:cache first, then call systemctl reload octane last so the new workers boot against the new schema and cached config in one clean step.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.