laravel
Laravel Octane in production — RoadRunner vs Swoole vs FrankenPHP, the numbers we actually see
We've migrated dozens of Laravel apps to Octane on three different runtimes. Here's how RoadRunner, Swoole, and FrankenPHP compare on throughput, memory, deployment ergonomics, and the failure modes that bite you in production.
13 de maio de 2026 · 11 min · por Sudhanshu K.
Laravel Octane in production — RoadRunner vs Swoole vs FrankenPHP, the numbers we actually see
The pitch for Laravel Octane is simple: boot the framework once, serve thousands of requests on each worker without reloading anything, and watch your response times drop by 5-10x. The pitch is largely true. The footnotes are where most teams get hurt.
We've migrated dozens of customer Laravel applications from php-fpm to Octane across all three supported runtimes — RoadRunner, Swoole, and (since 2024) FrankenPHP. This is what each one looks like in production, the throughput we measure, and the gotchas that don't appear in the documentation.
The baseline — what php-fpm actually gives you
Before discussing Octane, the thing you're moving away from. A typical Laravel 11 application on PHP 8.3, OPCache enabled, on a 4 vCPU box with 8GB RAM, behind nginx:
- Median request latency (warm cache, JSON API): 65-95ms
- p95 latency: 180-280ms
- Throughput per box (mixed read/write workload): 280-420 req/sec
- Memory per FPM worker: 80-120MB
- Boot cost per request (autoload + container + service providers): 15-30ms
The boot cost is the entire Octane opportunity. Octane runs the boot once, holds the booted app in memory, and serves requests from that already-warm state. The 15-30ms per-request boot disappears.
What you give up is process isolation. With FPM, each request gets a fresh process state. With Octane, state leaks between requests if your code (or your dependencies' code) holds references it shouldn't.
RoadRunner — the boring choice that mostly works
RoadRunner is a Go-based application server that talks to PHP workers over a pipe. It's been around the longest, it's the most operationally boring of the three, and for that reason it's our default recommendation for teams who don't have a specific reason to choose otherwise.
The numbers we typically see on the same 4 vCPU box:
- Median request latency: 12-22ms (5x improvement over FPM)
- p95 latency: 45-90ms (3-4x improvement)
- Throughput: 1,400-2,100 req/sec (4-5x improvement)
- Memory per worker (8 workers): 180-240MB
- Memory growth over 100k requests: 30-60MB (acceptable with periodic recycling)
The configuration is straightforward:
# .rr.yaml
version: "3"
server:
command: "php artisan octane:start --server=roadrunner"
http:
address: "0.0.0.0:8000"
pool:
num_workers: 8
max_jobs: 500
allocate_timeout: 60s
destroy_timeout: 60s
logs:
mode: production
level: warnThe max_jobs: 500 is critical. It tells RoadRunner to recycle each worker after 500 requests. This is the lever you pull when something in your application has a slow memory leak — and something in your application will have a slow memory leak. Setting it too low means more boot cost; too high means workers grow until the box swaps. We start at 500 and tune from there.
What goes wrong with RoadRunner:
- Worker crashes silently if a PHP error escapes the request handler. RoadRunner respawns it, but you lose the request. Wire
octane:reloadto deploy and monitorrr workers -ifor restart counts. - Slow requests block a worker. 8 workers means 8 concurrent requests. A request that takes 30 seconds locks one of your 8 slots. Use queues for anything slow, and set
http.timeoutto kill runaways. - State leakage. We've seen
Auth::user()from request N served to user in request N+1 because some package cached the auth user in a singleton. Octane has theflush()lifecycle hooks to clean this up — use them.
Swoole — the fast one, with sharper edges
Swoole is a C extension that turns PHP into an event-driven async runtime. It's significantly faster than RoadRunner for the workloads that suit it, and significantly more dangerous if your code isn't ready.
Numbers from the same machine:
- Median request latency: 8-15ms (slightly faster than RoadRunner)
- p95 latency: 30-65ms
- Throughput: 1,800-3,200 req/sec (often noticeably higher than RoadRunner for IO-heavy workloads)
- Memory per worker (8 workers): 220-310MB
- Coroutines — concurrent IO within a single request
The coroutine support is the differentiator. If your Laravel application makes 5 parallel HTTP calls to upstream services per request, Swoole + Octane::concurrently() runs them genuinely concurrently within the worker:
use Laravel\Octane\Facades\Octane;
[$users, $orders, $shipments] = Octane::concurrently([
fn () => Http::get('https://users.internal/v1/list'),
fn () => Http::get('https://orders.internal/v1/list'),
fn () => Http::get('https://shipments.internal/v1/list'),
]);On a request that previously took 240ms (three serialized 80ms upstream calls), Swoole brings it down to ~90ms. RoadRunner can't do this — it serializes.
What goes wrong with Swoole:
- Globals leak across requests much more readily.
staticproperties, global container bindings, even somegetenv()calls — anything that survives a request can pollute the next one. We spend the first week of every Swoole migration finding these. - Database connections held by Eloquent need to be reset between requests, otherwise a transaction left open in request N causes mayhem in request N+1. Octane's
DatabaseTransactionslistener handles this, but custom code that uses raw PDO often doesn't. - Debugging is harder. Xdebug doesn't play well with Swoole. We've moved customers to Tideways or Blackfire for production profiling, which is arguably an improvement.
- Memory grows faster. Plan on recycling workers more aggressively —
max_requests: 250is our starting point versus 500 for RoadRunner.
We use Swoole for high-throughput API workloads where the IO concurrency genuinely matters. For a typical CRUD application, RoadRunner is enough and easier to operate.
FrankenPHP — the new entrant that we've been quietly impressed by
FrankenPHP is a single binary built on Caddy + PHP, released in late 2023 and stabilised through 2024-2025. It's now production-ready for Laravel Octane and brings a few things the others don't:
- HTTP/2 and HTTP/3 out of the box (no separate reverse proxy needed for those)
- Automatic Let's Encrypt
- Worker mode that's competitive with Swoole on throughput
- Single-binary deployment (no separate PHP-FPM + nginx + RoadRunner stack)
Numbers on the same hardware:
- Median request latency: 10-18ms
- p95 latency: 35-70ms
- Throughput: 1,600-2,800 req/sec
- Memory per worker: 200-280MB
FROM dunglas/frankenphp:latest
ENV SERVER_NAME=:80
ENV FRANKENPHP_CONFIG="worker ./public/frankenphp-worker.php"
COPY . /app
WORKDIR /app
RUN composer install --no-dev --optimize-autoloader
RUN php artisan octane:install --server=frankenphpThe deployment story is the part we've come to like most. A single container, no reverse proxy, no separate process supervisor. For customer Laravel apps on GCP Cloud Run, this maps neatly onto the platform — one image, one port, scales horizontally.
What goes wrong with FrankenPHP:
- It's the youngest of the three. We've hit two memory leak regressions in production over the last 18 months. Both got fixed promptly upstream, but if you need a runtime that's been stable for 5+ years, RoadRunner is still that choice.
- Configuration ergonomics are different. It's Caddy config, not nginx config. Some teams find this freeing; others find it unfamiliar.
- The worker-mode docs lag the code. When something behaves oddly, you sometimes have to read the source.
State leakage — the failure mode you actually need to fix
This applies to all three runtimes equally. The single biggest source of post-Octane-migration bugs is application state that was implicitly reset by php-fpm and is now persistent.
Things to audit, in roughly the order they bite us:
Singletons in the container. A service registered with $this->app->singleton(...) is created once per worker, not once per request. If it holds the current user, the current tenant, or the current request — disaster.
// Bad — survives across requests
$this->app->singleton(TenantContext::class, function () {
return new TenantContext(request()->user()->tenant_id);
});
// Good — bound to scoped (request) instance
$this->app->scoped(TenantContext::class, function () {
return new TenantContext(request()->user()->tenant_id);
});scoped() was added precisely for this case. Use it everywhere a singleton has any request-derived state.
Static properties on your own classes. Anything you've added to make a value globally accessible — flush it on RequestReceived or rewrite to not be static.
config() mutations. Code that calls config(['x.y' => $value]) during a request will permanently mutate config for the worker. We grep for this in every migration.
Eloquent model events registered via Model::saved(...) in a service provider's boot(). They register once per worker boot, which is fine. The danger is closures that capture request-scoped state.
Database transactions left open. If a request crashes inside DB::beginTransaction() and the catch block doesn't roll back, the next request gets the open transaction. Octane's listener helps; defensive coding is better.
Octane provides hooks for exactly this cleanup. We always wire at minimum:
// config/octane.php
'flush' => [
\App\Services\TenantContext::class,
\App\Services\AuditLogBuffer::class,
],
'listeners' => [
RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(),
],
WorkerErrorOccurred::class => [
ReportException::class,
StopWorkerIfNecessary::class,
],
WorkerStopping::class => [
CloseMonologHandlers::class,
],
],When NOT to use Octane
Octane is not the right answer for every Laravel application. We've talked customers out of migrating in these situations:
- Heavy use of packages that aren't Octane-safe. This is a shrinking list, but it still exists. Check each major package's compatibility before committing.
- Very low traffic. If your app does 50 requests per minute, the cost of operating Octane (worker management, deploy complexity, debugging) isn't worth the latency gain. Stay on FPM.
- Workloads that are CPU-bound per request, not boot-bound. If your average request spends 800ms in a PDF library and 5ms in framework code, Octane saves you the 5ms. Not worthwhile.
- Teams without the bandwidth to handle state leakage debugging. Octane debugging is a different skill. If the team is stretched thin, the migration is a poor time to add another novel failure mode.
What we run for customers by default
For typical managed Laravel customers on AWS and GCP with non-trivial traffic, our standard 2026 configuration:
- API-heavy, microservice-ish workload: Octane on Swoole, 8-16 workers per box,
max_requests: 250, with extensive use ofOctane::concurrently()for upstream calls - Traditional monolith, mixed web + API: Octane on RoadRunner, 8 workers per box,
max_jobs: 500, FPM kept as a fallback for any legacy endpoints we haven't verified - Containerised, cloud-native, autoscaled: FrankenPHP, single-binary, deployed via the PHP-aware provisioning pipeline we run
We benchmark the move. Before-and-after numbers from real traffic — not synthetic — go into a report the customer can read. The latency drops are real; the migration risk is also real, and that report is what makes the decision defensible.
Reach out if you want us to run the migration. It's typically a two-week project for a moderately sized Laravel application, including the state-leakage audit, the runtime selection, the deploy pipeline changes, and a fortnight of co-operation while we watch for the slow leaks.
Sudhanshu K. is a Staff DevOps engineer at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). He has migrated more than 40 Laravel apps to Octane across the three supported runtimes, and has a small private collection of state-leakage bugs that he occasionally trots out at meetups.