DEV Community

Cover image for Laravel 12 Octane: FrankenPHP vs Swoole vs RoadRunner in 2026
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel 12 Octane: FrankenPHP vs Swoole vs RoadRunner in 2026


You ship a Laravel 12 API behind PHP-FPM. p95 sits at 180ms. Two weeks later, traffic doubles, and the same endpoint hits 420ms. The hot path didn't get slower. The bootstrap got more expensive. Every request is paying for framework boot, container resolution, and config parsing. Octane fixes that by keeping the app in memory between requests, and in 2026 you've got three drivers to pick from: FrankenPHP, Swoole, and RoadRunner.

This post is the honest comparison. Not "which one has more stars". Which one survives your deploy pipeline, your WebSocket needs, and the memory-leak gotcha that bites all three.

Why Octane still matters: the request-per-second math

PHP-FPM forks a worker per request. Each worker boots Laravel from scratch: autoload, service providers, config cache, route cache, container bindings. On a modest API, that boot phase eats 30–80ms per request before your controller even runs.

Octane keeps the application booted. The worker stays warm, the container stays resolved, and your request handler runs against an already-hot state. The official Octane benchmarks show 5–10× throughput improvements on standard CRUD endpoints. Your mileage will vary. Endpoints heavy in I/O won't see the same jump as endpoints heavy in framework boot. The math is real.

There's a cost. Worker reuse means state from request N can leak into request N+1 unless you're disciplined. We'll get to that. First, the drivers.

FrankenPHP: Caddy as the runtime

FrankenPHP is a single Go binary that embeds PHP. It runs as a Caddy module, which means HTTPS, HTTP/3, and automatic certificate management come for free. The official Laravel Octane docs marked it as a first-class driver in late 2024, and by 2026 it's the easiest deploy story on the list.

Installation on a fresh Laravel 12 app:

composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan octane:start --server=frankenphp --workers=4 --max-requests=500
Enter fullscreen mode Exit fullscreen mode

The octane.php config for FrankenPHP looks like this:

// config/octane.php
return [
    'server' => env('OCTANE_SERVER', 'frankenphp'),

    'https' => env('OCTANE_HTTPS', false),

    'listeners' => [
        WorkerStarting::class => [
            EnsureUploadedFilesAreValid::class,
            EnsureUploadedFilesCanBeMoved::class,
        ],
        RequestReceived::class => [
            ...Octane::prepareApplicationForNextOperation(),
            ...Octane::prepareApplicationForNextRequest(),
        ],
        RequestTerminated::class => [
            FlushUploadedFiles::class,
        ],
        TaskReceived::class => [...Octane::prepareApplicationForNextOperation()],
        TickReceived::class => [...Octane::prepareApplicationForNextOperation()],
        OperationTerminated::class => [
            FlushTemporaryContainerInstances::class,
        ],
        WorkerStopping::class => [],
    ],

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

    'flush' => [],

    'cache' => [
        'rows' => 1000,
        'bytes' => 10000,
    ],

    'tables' => [
        'example:1000' => [
            'name' => 'string:1000',
            'votes' => 'int',
        ],
    ],
];
Enter fullscreen mode Exit fullscreen mode

Deploy on production with a Caddyfile next to your app:

{
    frankenphp
    order php_server before file_server
}

api.example.com {
    root * /var/www/app/public

    encode zstd br gzip

    php_server {
        worker /var/www/app/public/index.php {
            num 8
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. Caddy handles TLS via Let's Encrypt automatically, the worker directive boots Octane's worker pool, and you don't need nginx, php-fpm, or systemd unit files for each layer. One binary, one config, HTTPS at the edge.

The catch: FrankenPHP's worker mode is newer than Swoole or RoadRunner. The ecosystem around debugging worker-state issues is still maturing. If you hit a weird state-leak bug, you're more on your own than with the older drivers.

Swoole: the mature option with a C-extension cost

Swoole is a PECL extension. It's been around since 2012, and the Laravel community adopted it early. The concurrency model is real: coroutines, async I/O, the whole pattern that Go developers recognize on sight.

Installation needs the extension first:

pecl install swoole
# add extension=swoole.so to your php.ini

composer require laravel/octane
php artisan octane:install --server=swoole
php artisan octane:start --server=swoole --workers=4 --task-workers=2
Enter fullscreen mode Exit fullscreen mode

The Swoole-specific Octane config:

// config/octane.php (Swoole-specific bits)
'swoole' => [
    'options' => [
        'log_file' => storage_path('logs/swoole_http.log'),
        'package_max_length' => 10 * 1024 * 1024,
    ],
],
Enter fullscreen mode Exit fullscreen mode

You also pass server options via the command:

php artisan octane:start \
    --server=swoole \
    --host=0.0.0.0 \
    --port=8000 \
    --workers=4 \
    --task-workers=2 \
    --max-requests=500
Enter fullscreen mode Exit fullscreen mode

Swoole gives you things FrankenPHP and RoadRunner don't: real coroutines, async HTTP clients via Swoole\Coroutine\Http\Client, and built-in WebSocket support that's been battle-tested for a decade. If you're running long-lived WebSocket connections (chat, presence, real-time dashboards), Swoole is still the safer bet in 2026.

The cost is the C-extension itself. You can't pin a PHP version and forget about it; every PHP upgrade means a Swoole rebuild. Docker images get larger. Some PHP extensions (xdebug being the obvious one) interact badly with Swoole's worker model and need explicit disabling in production. And the coroutine model, while real, leaks into your code: you call Swoole\Coroutine::create() and now you're writing concurrent PHP, which most teams aren't trained for.

RoadRunner: Go-based, the PHP-RPC bridge

RoadRunner is written in Go. It runs as a separate process and talks to PHP workers over a binary protocol. The bridge is fast (sub-millisecond overhead per request), and the deployment story is cleaner than Swoole because there's no C extension to install.

Installation:

composer require laravel/octane spiral/roadrunner-cli
php artisan octane:install --server=roadrunner
./vendor/bin/rr get-binary
php artisan octane:start --server=roadrunner --workers=4 --max-requests=500
Enter fullscreen mode Exit fullscreen mode

You get a .rr.yaml file at the project root. The relevant bits:

version: '3'

server:
  command: 'php artisan octane:start --server=roadrunner'

http:
  address: 0.0.0.0:8000
  middleware: ['static', 'gzip']
  pool:
    num_workers: 4
    max_jobs: 500
    allocate_timeout: 60s
    destroy_timeout: 60s

  static:
    dir: 'public'
    forbid: ['.php', '.htaccess']

logs:
  mode: production
  level: warn

reload:
  interval: 1s
  patterns: ['.php']
  services:
    http:
      dirs: ['app', 'bootstrap', 'config', 'routes']
Enter fullscreen mode Exit fullscreen mode

RoadRunner's strength is the Go runtime underneath. It handles graceful reloads cleanly, has solid metrics endpoints out of the box (Prometheus on /metrics if you enable the plugin), and the worker pool behaves predictably under load. If you're already running Go services elsewhere in the stack, the operational model will feel familiar.

The weakness: RoadRunner historically had rough edges with Laravel-specific features like queues and broadcasting. The 2.x release line (current in 2026) closed most of those gaps, but the community is smaller than Swoole's. When something breaks, you're searching narrower forums.

The benchmark: wrk against Laravel 12 on 4 cores

A benchmark is only useful if the methodology is honest. Here's what works.

Test setup: Laravel 12.x on PHP 8.4, a single endpoint that fetches a User by ID from Postgres (with withCount('orders')), serializes it via an API Resource, and returns JSON. 4-core VM, 8GB RAM, no other tenants. Octane configured with 4 workers per driver. Postgres on a separate host so we measure the runtime, not the DB.

Tool: wrk because it's the right tool for HTTP load. ab is too slow. hey is fine but wrk gives you latency histograms.

# warm up
wrk -t 4 -c 50 -d 30s http://localhost:8000/api/users/42

# the actual run
wrk -t 4 -c 100 -d 60s --latency http://localhost:8000/api/users/42
Enter fullscreen mode Exit fullscreen mode

Run the benchmark three times per driver, throw out the first run (cold caches), and report the median of the remaining two. Always do the warm-up phase or your numbers will be noise.

I'm not going to put fabricated numbers in this post. The Laravel Octane documentation and the Octane GitHub repo maintain their own benchmark suites, and your stack will differ. The shape that's consistent across published runs:

  • FrankenPHP and Swoole trade the top spot depending on workload. FrankenPHP edges out on CPU-light JSON endpoints; Swoole edges out when there's async I/O the coroutines can interleave.
  • RoadRunner lands within 5–10% of the leader on most workloads. The Go process boundary adds a small overhead that's measurable but rarely matters.
  • All three are 4–8× faster than PHP-FPM on bootstrap-heavy endpoints. On endpoints where the DB query dominates, the gap collapses to 1.2–1.5× because you're measuring Postgres, not PHP.

The honest takeaway: don't pick a driver because of a 3% benchmark difference. Pick it because of the deploy model that fits your team.

The memory-leak gotcha that bites all three

This is the one. The reason "Octane is faster" turns into "Octane crashed our prod at 2am."

In PHP-FPM, every request gets a fresh process. Global state, static properties, and singletons die at the end of the request. Memory leaks self-heal.

In Octane, the worker lives across requests. Static state sticks around. Singletons stick around. Anything you bound into the container as a singleton sticks around. That's where the leak lives.

A real shape that bites teams:

// app/Services/PricingCache.php
class PricingCache
{
    // singleton-scoped, lives for the worker's lifetime
    private array $cache = [];

    public function get(int $productId): ?Price
    {
        if (isset($this->cache[$productId])) {
            return $this->cache[$productId];
        }

        $price = Price::where('product_id', $productId)->first();
        $this->cache[$productId] = $price;

        return $price;
    }
}
Enter fullscreen mode Exit fullscreen mode

Bind it as a singleton:

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(PricingCache::class);
}
Enter fullscreen mode Exit fullscreen mode

Under FPM, the cache is a per-request optimization. Under Octane, that $cache array grows for the entire worker's life. Hit 10,000 unique products and the worker is holding 10,000 Price Eloquent models in memory, including their relations, their original attributes, and their connection references. Memory grows monotonically until --max-requests cycles the worker (or the OOM killer does it for you).

The fix is twofold.

First, don't store unbounded state in singletons. If you want a per-request cache, scope it to the request:

// scope by binding it inside a middleware or using the request macro
app()->bind(PricingCache::class, fn () => new PricingCache());
// or use Cache::driver('array') which Laravel flushes between Octane requests
Enter fullscreen mode Exit fullscreen mode

Second, configure flush in octane.php for anything that does need to die between requests:

// config/octane.php
'flush' => [
    PricingCache::class,
    // any other singleton with mutable state
],
Enter fullscreen mode Exit fullscreen mode

That tells Octane to call $app->forgetInstance($class) between requests, forcing a re-resolve on the next one.

And always set --max-requests. It's your safety net. Even with disciplined code, native extensions and third-party libraries leak. Cycling the worker every 500–1000 requests bounds your worst case to "a small memory blip" instead of "OOM crash."

php artisan octane:start --server=frankenphp --workers=4 --max-requests=500
Enter fullscreen mode Exit fullscreen mode

That --max-requests=500 line is non-negotiable in production. All three drivers respect it.

When FrankenPHP wins

You want a single binary. You want HTTPS without nginx in front. You want HTTP/3. You're deploying to a VM, a bare-metal box, or a container where one process is the whole story. FrankenPHP is the cleanest deploy on the list and the documentation has caught up. For a Laravel 12 API or admin panel in 2026, it's my default recommendation.

When Swoole wins

You're running WebSockets at scale. You need coroutines for fan-out HTTP calls. You're already on Swoole and the upgrade cost is non-zero. Swoole's WebSocket implementation has been in production at companies like Tencent for over a decade, and that maturity matters when the connection count goes past 100k.

When RoadRunner wins

Your infra team runs Go services and wants the same operational shape for PHP. You need first-class Prometheus metrics on the runtime layer. You want graceful reloads that don't drop in-flight requests. RoadRunner's Go process gives you the best metrics story of the three, and the worker pool behaves predictably under burst load.

The decision tree

Plain prose, no triple-beat:

If you're starting fresh in 2026 and just need a fast Laravel app, go FrankenPHP. The deploy story alone saves you a day of yak-shaving.

If you already run Swoole and your app uses coroutines or WebSockets, stay. The migration cost isn't worth the marginal benchmark gain.

If your platform team standardizes on Go-based runtimes and wants Prometheus-native PHP, RoadRunner is the right cultural fit.

If none of those describe you and you're trying to wring more out of PHP-FPM first, stop. Tune your Postgres queries, add Redis where you're hitting the DB on every request, and only reach for Octane when the framework boot is genuinely your bottleneck. Octane multiplies throughput; it doesn't fix slow queries.

The driver question matters less than the worker-state discipline. Pick the driver that fits your deploy story, set --max-requests, audit your singletons, and you'll get the speedup without the 2am page.

Which Octane driver are you running in production, and what bit you the hardest when you migrated off PHP-FPM?


If this was useful

Octane gets you raw throughput. What it can't give you is a domain layer clean enough to survive the next framework upgrade, and once your app stays warm across requests, the architectural seams matter more, not less. Decoupled PHP is the architectural layer your codebase reaches for after it outgrows the framework defaults: hexagonal boundaries, use cases, and ports that keep your business logic alive even when you swap out Octane drivers, ORMs, or PHP runtimes.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)