PHP-FPM has served us well for fifteen years, but it boots Laravel from scratch on every request 50 to 150ms wasted before your first line of business logic runs.
Octane keeps the framework in memory and serves requests from a long-running worker process; FrankenPHP is the modern app server that makes Octane production-ready without the operational baggage of Swoole or RoadRunner. This guide is the recipe my team used to cut p95 latency by more than half.
What you will get from this guide
- Why Octane + FrankenPHP is now the right default
- A clean install path that does not break Forge or Vapor deploys
- The "stateful container" gotchas that cause 90% of Octane bugs
- Concurrent task dispatch with the new Concurrency facade
- Real benchmark numbers and a deployment checklist
requests per second on the same hardware vs PHP-FPM
Info
What is Octane, in 30 seconds
A Laravel package that runs your app inside a worker process from FrankenPHP, Swoole, or RoadRunner. Each worker boots Laravel once and reuses the bootstrapped framework across thousands of requests no rebooting per request, no opcache cold starts.
1. Why FrankenPHP is the right Octane backend in 2026
✓ Pros
- Single static binary no Swoole compile, no RoadRunner sidecar
- Built on the Caddy server: HTTPS, HTTP/3, and zstd compression for free
- Worker mode supports the full PHP feature set (no async-only restrictions)
- Native HTTP/2 push and 103 Early Hints for fast first paint
✕ Cons
- Newer than Swoole community recipes are still catching up
- Some legacy extensions assume process-per-request model
- On Forge, you pick the FrankenPHP server type at provisioning
2. Installing on a fresh Laravel 12 app
composer require laravel/octane
php artisan octane:install --server=frankenphp
# Local dev
php artisan octane:frankenphp --workers=auto --max-requests=500
# Production (Forge or Vapor handles this; for raw VPS:)
php artisan octane:frankenphp --host=0.0.0.0 --port=8000 --workers=8 --max-requests=1000
Warning
max-requests is your safety net
Workers leak memory eventually every framework does. Setting <code>--max-requests=1000</code> recycles each worker after handling 1000 requests, releasing leaked memory automatically. Do not skip this.
3. The stateful container: where Octane bites you
Under PHP-FPM, every request gets a fresh container. Under Octane, the container persists. Anything you bind as a singleton including authenticated user, current request, locale is reused across requests unless you reset it. This is the source of the most surprising Octane bugs.
// BAD: this singleton survives across requests
$this->app->singleton(ReportBuilder::class, function ($app) {
return new ReportBuilder($app['request']->user());
});
// First request: built with User A. Every subsequent request: still User A.
// GOOD: bind as scoped, reset between requests
$this->app->scoped(ReportBuilder::class, function ($app) {
return new ReportBuilder($app['request']->user());
});
Danger
Audit your service providers before going live
Open every <code>app/Providers/*</code> file. Every <code>singleton</code> that touches request, user, locale, session, or cache should be <code>scoped</code> instead. Mixing them up causes one user to see another user's data silently.
4. Concurrent dispatch with the Concurrency facade
Octane unlocks parallel work that simply does not exist under PHP-FPM. The Concurrency facade dispatches multiple closures and waits for all of them.
use Illuminate\Support\Facades\Concurrency;
[$user, $orders, $tickets] = Concurrency::run([
fn () => User::with('profile')->find($id),
fn () => Order::where('user_id', $id)->latest()->take(10)->get(),
fn () => SupportTicket::where('user_id', $id)->open()->get(),
]);
return view('dashboard', compact('user', 'orders', 'tickets'));
The three queries run in parallel, the dashboard total time is the slowest one, not the sum. This pattern collapses three 80ms queries (240ms serial) into roughly 90ms.
5. Caching things that should never be re-resolved
// app/Providers/AppServiceProvider.php
public function boot(): void
{
if (app()->runningInConsole()) {
return;
}
// Cache the merged config tree across all workers, populated once at boot.
Octane::tick('refresh-feature-flags', function () {
Cache::forever('feature_flags', FeatureFlag::all()->keyBy('key'));
})->seconds(60)->immediate();
}
Pro tip
Octane ticks run in the background of every worker. Use them for expensive cache warmups, NOT for jobs that should be queued. A tick that takes 5 seconds blocks one worker for 5 seconds.
6. Real benchmarks (Forge, $40 Hetzner CX31, 4 vCPU / 8GB RAM)
Endpoint: GET /api/dashboard (auth + 4 queries)
PHP-FPM (php-fpm 8.3, 16 workers):
Requests/sec: 712
p50 latency: 18ms
p95 latency: 98ms
p99 latency: 188ms
FrankenPHP + Octane (8 workers, max-requests=1000):
Requests/sec: 3,184
p50 latency: 4ms
p95 latency: 32ms
p99 latency: 71ms
Success
These numbers are typical, not best-case
I have run these benchmarks on every project I have moved to Octane in the last two years. The 4–5× throughput and ~3× latency improvement are reproducible. The bigger your bootstrap (Filament, Nova, Spatie packages), the more you save.
7. Production checklist
Run `php artisan octane:status` in your healthcheck
It exposes worker count and memory usage. Alert if any worker exceeds 256MB.
Reload, do not restart, on deploy
`php artisan octane:reload` zero-downtime swaps workers. `octane:stop` causes a 1–2 second hiccup.
Move long-running work to queues
A 30-second request blocks a worker for 30 seconds. With 8 workers and 100 RPS of normal traffic, one slow request can stall everything.
Pin the FrankenPHP version in your Dockerfile
Major version upgrades have changed CLI flags between point releases. Lock to a tag, upgrade deliberately.
When NOT to use Octane
Note
Octane is overkill below 50 RPS
If your app handles fewer than 50 requests per second, the operational complexity is not worth the gain. Stick with PHP-FPM, focus on caching and database indexes, and revisit Octane when traffic justifies it.
For everyone else: Octane + FrankenPHP is the new default for production Laravel. Install it, audit your singletons, set --max-requests, ship it.