DEV Community

Cover image for FrankenPHP vs RoadRunner vs Swoole: A Production Benchmark (2026 Edition)
Gabriel Anhaia
Gabriel Anhaia

Posted on

FrankenPHP vs RoadRunner vs Swoole: A Production Benchmark (2026 Edition)


Three benchmark blog posts cross your feed in the same week. FrankenPHP claims 4x faster than php-fpm. RoadRunner claims even more. Swoole has been claiming "C-level performance" for half a decade. None of them are lying. They're also not telling you the truth that matters, which is: the workload they benchmarked has almost nothing in common with yours.

This post isn't "here are the numbers, pick a winner." It's the opposite. It's the methodology you need before any benchmark number is worth anything, plus the three runtimes side by side on what they actually do well, plus the one config mistake that wrecks most public benchmarks comparing them.

Why the marketing-page numbers lie

A typical "FrankenPHP is N times faster" chart runs wrk against a route that returns "hello". No DB. No cache lookup. No JSON encoding. No middleware. The benchmark measures how fast the runtime can hand bytes to a TCP socket. Your Laravel app spends 3% of its time on that.

The same chart for RoadRunner and Swoole is just as misleading for the same reason. When the workload is "echo hello," you're measuring boot time amortisation and the cost of the runtime's request loop. That's a real number, but it doesn't tell you what happens when 60% of your wall time is a Postgres roundtrip.

The real comparison is harder. You need a workload that resembles production: a DB query, an HTTP call to a downstream service, a session lookup, some JSON encoding, a render. And you need to control for what's actually being compared. Not the runtime alone, but the entire stack including connection pooling, OPcache state, and worker memory growth.

So this post does two things. It shows you the configs for all three, so you can run the comparison yourself. And it tells you the specific gotcha that invalidates 80% of the benchmarks you'll find on the internet.

The setup that resembles real work

Pick a workload that exercises what your app actually does. A reasonable baseline for a Laravel or Symfony app:

  • One route. GET /orders/{id}.
  • Loads an order from Postgres (1 row).
  • Loads the order's line items (3-5 rows).
  • Computes a total in PHP.
  • Returns JSON.

That's it. No artificial CPU work. No artificial sleeps. The route is boring on purpose, because boring is what production looks like.

Run it for at least 5 minutes per configuration. Warm up for 60 seconds before recording. Track:

  • Requests per second (mean, not peak).
  • Latency at p50, p95, p99.
  • RSS memory per worker, sampled every 10 seconds.
  • CPU utilisation per worker.

One run isn't a benchmark, it's a data point. Run each configuration three times, drop the outlier, average the rest.

FrankenPHP: the single-binary deploy

FrankenPHP is Caddy with PHP embedded as a module. One binary. No php-fpm, no nginx, no separate process supervisor. It also has a worker mode where your PHP boots once and serves many requests, which is where the real speed comes from. The official FrankenPHP documentation explains the worker model in detail.

A minimal Caddyfile for a Laravel app in worker mode:

{
    frankenphp {
        # the docs say 1 worker per CPU; in practice
        # you want fewer if each worker holds connections
        worker /app/public/frankenphp-worker.php 4
    }
}

:8080 {
    root * /app/public
    encode zstd br gzip

    php_server {
        # static files served by Caddy, not PHP
        try_files {path} /index.php
    }
}
Enter fullscreen mode Exit fullscreen mode

And the worker script (public/frankenphp-worker.php):

<?php
// boot Laravel once
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$kernel = $app->make(\Illuminate\Contracts\Http\Kernel::class);

// FrankenPHP request loop, runs forever
$handler = static function () use ($kernel) {
    $request = \Illuminate\Http\Request::capture();
    $response = $kernel->handle($request);
    $response->send();
    $kernel->terminate($request, $response);
};

$maxRequests = (int)($_SERVER['MAX_REQUESTS'] ?? 1000);
for ($i = 0; $i < $maxRequests; $i++) {
    $keepRunning = \frankenphp_handle_request($handler);
    gc_collect_cycles();
    if (!$keepRunning) break;
}
Enter fullscreen mode Exit fullscreen mode

Note the gc_collect_cycles() call. PHP's reference-counting GC handles most things, but long-lived workers accumulate cycles that only the full collector clears. Without that line, memory grows linearly until your worker hits OOM. This is the first long-running-PHP gotcha and it bites everyone exactly once.

FrankenPHP's pitch is simplicity. The deploy story is COPY frankenphp /usr/local/bin/ && CMD ["frankenphp", "run"]. No supervisor, no separate webserver config, TLS handled by Caddy. For a small team, that's a real win that doesn't show up in req/s charts.

RoadRunner: PHP workers managed by a Go process

RoadRunner is a Go binary that manages a pool of PHP worker processes. Each worker boots PHP once and serves many requests through a custom protocol (goridge). The supervisor in Go handles HTTP, load balancing, graceful restarts, and worker lifecycle. The RoadRunner documentation covers the architecture and configuration options.

A minimal .rr.yaml:

version: '3'

rpc:
  listen: tcp://127.0.0.1:6001

server:
  command: 'php worker.php'
  relay: pipes

http:
  address: 0.0.0.0:8080
  middleware: ['gzip', 'static']
  static:
    dir: 'public'
    forbid: ['.php', '.htaccess']
  pool:
    num_workers: 4
    max_jobs: 1000          # restart worker after N requests
    allocate_timeout: 60s
    destroy_timeout: 60s
    supervisor:
      watch_tick: 1s
      ttl: 0s
      idle_ttl: 10s
      max_worker_memory: 256  # MB, restart if exceeded
      exec_ttl: 30s

logs:
  mode: production
  level: warn
Enter fullscreen mode Exit fullscreen mode

The PHP side (worker.php) for Laravel via the spiral/roadrunner-laravel bridge:

<?php
require __DIR__ . '/vendor/autoload.php';

use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker;
use Nyholm\Psr7\Factory\Psr17Factory;

$app = require __DIR__ . '/bootstrap/app.php';
$kernel = $app->make(\Illuminate\Contracts\Http\Kernel::class);

$worker = Worker::create();
$psr17 = new Psr17Factory();
$psr7 = new PSR7Worker($worker, $psr17, $psr17, $psr17);

while ($req = $psr7->waitRequest()) {
    try {
        // convert PSR-7 to Laravel request, handle, convert back
        $laravelRequest = \Illuminate\Http\Request::createFromBase(
            (new \Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory())
                ->createRequest($req)
        );
        $response = $kernel->handle($laravelRequest);
        $psrResponse = (new \Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory(
            $psr17, $psr17, $psr17, $psr17
        ))->createResponse($response);
        $psr7->respond($psrResponse);
        $kernel->terminate($laravelRequest, $response);
        gc_collect_cycles();
    } catch (\Throwable $e) {
        $psr7->getWorker()->error((string)$e);
    }
}
Enter fullscreen mode Exit fullscreen mode

RoadRunner's biggest practical advantage is the supervisor. max_worker_memory: 256 will restart any worker that exceeds 256MB RSS, which means a memory leak in your code or a third-party library doesn't take the server down. It just costs you a recycled worker. max_jobs: 1000 does the same for request-count-based recycling. Both are essential for long-running PHP, and both are easier to configure in RoadRunner than in the other two.

The downside is the indirection. Two languages, two debug toolchains, two log streams. When something goes wrong at 3am, you're now looking at Go traces too.

Swoole: PHP with a different runtime model

Swoole is a PHP extension that gives you an event loop, coroutines, and a built-in HTTP server. It's been around the longest. Swoole 1.0 shipped in 2012, long before FrankenPHP or RoadRunner existed. It's also the most opinionated. Swoole assumes you're going to write coroutine-aware code, and it works best when you do.

A basic Swoole HTTP server (server.php):

<?php
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;

require __DIR__ . '/vendor/autoload.php';

$server = new Server('0.0.0.0', 8080, SWOOLE_PROCESS, SWOOLE_SOCK_TCP);

$server->set([
    'worker_num'        => 4,
    'max_request'       => 1000,        // recycle worker after N requests
    'max_conn'          => 10000,
    'task_worker_num'   => 0,
    'enable_coroutine'  => true,
    'hook_flags'        => SWOOLE_HOOK_ALL,  // coroutine-hook everything
    'log_level'         => SWOOLE_LOG_WARNING,
]);

// boot Laravel once, in the worker, NOT in the master
$server->on('workerStart', function (Server $server, int $workerId) {
    require __DIR__ . '/bootstrap/app.php';
    // store the kernel on the server context
    $server->kernel = app(\Illuminate\Contracts\Http\Kernel::class);
});

$server->on('request', function (Request $req, Response $res) use ($server) {
    $laravelRequest = \Illuminate\Http\Request::create(
        $req->server['request_uri'],
        $req->server['request_method'],
        array_merge($req->get ?? [], $req->post ?? []),
    );
    $response = $server->kernel->handle($laravelRequest);
    $res->status($response->getStatusCode());
    foreach ($response->headers->all() as $name => $values) {
        $res->header($name, $values[0]);
    }
    $res->end($response->getContent());
    $server->kernel->terminate($laravelRequest, $response);
});

$server->start();
Enter fullscreen mode Exit fullscreen mode

Swoole's killer feature is enable_coroutine + SWOOLE_HOOK_ALL. Once those are on, every blocking call (PDO, file_get_contents, curl, sleep) runs inside a coroutine. A single worker can handle thousands of concurrent connections without thread or process overhead. This is genuinely different from what FrankenPHP and RoadRunner do; they parallelise across workers, Swoole parallelises inside a worker.

For request/response workloads where each request is mostly waiting on a DB or upstream HTTP call, this matters. For pure CPU work, it doesn't. You've still got the same N cores.

The Laravel ecosystem has historically been awkward on Swoole because so much of the framework was written assuming a single-request-per-process model. Octane's Swoole driver papers over a lot of this, but you still hit edge cases with global state, static caches, and singletons that were never meant to outlive a request.

Where each one wins

These are the patterns that hold up across the methodology if you actually run it on a realistic workload:

FrankenPHP wins on deploy simplicity. One binary, TLS included, no nginx config, no fpm config, no supervisor. If you're shipping a small team's Laravel app to a single VPS, this is the lowest-friction option. Worker mode performance is competitive with the others within margin of error on a realistic workload.

RoadRunner wins on operational maturity. The supervisor is the best of the three. Memory limits, request limits, idle TTLs, graceful restarts, metrics endpoints, all built in and well-documented. If your ops team is more comfortable with structured config files than with extension flags, RoadRunner reads more naturally.

Swoole wins on websockets and high-concurrency I/O. Long-lived connections, chat servers, real-time dashboards. Swoole has been doing this since 2012 and the ecosystem reflects it. For a normal request/response API, the coroutine model is overkill, but the moment you need 10,000 concurrent connections on one box, the others aren't in the same conversation.

The req/s numbers on the JSON-orders workload tend to fall within 15-20% of each other on a fair comparison. That's much smaller than the marketing pages suggest, and within the variance of what your hosting provider's noisy neighbour will do to you on any given Tuesday.

The gotcha that wrecks every benchmark: connection pool sharing

Here's the mistake every public PHP-runtime benchmark makes, and the one to walk away from this post knowing.

In standard PHP under php-fpm, each request gets a fresh PDO connection (unless you use PDO::ATTR_PERSISTENT, which has its own pile of problems). The connection lives, queries, dies. There is no pool because there are no long-lived workers.

In FrankenPHP worker mode, RoadRunner, and Swoole, the worker is long-lived. The PDO connection it opens lives across requests. If you have 4 workers, you have 4 database connections. So far so good.

Now you bump worker_num to 32 to handle a traffic spike. You now have 32 connections. Multiply by 4 app servers behind a load balancer: 128 connections. Your Postgres max_connections is 100. Your benchmark suddenly shows worse p99 than fpm, and you have no idea why.

Worse: if you set a global static $pdo to "reuse the connection," but you do it in a constructor that fires per request, you've actually got the right connection but possibly the wrong transaction state if a previous request left an open transaction on it. Coroutine runtimes (Swoole) compound this. Two coroutines inside one worker can race on the same connection and your queries cross-pollinate.

The fix is workload-dependent but the principles are constant:

  1. Cap workers at the DB's connection budget. Total workers across all app instances must be < db.max_connections - reserved_for_admin - other_services.
  2. Use a pooler in front of the DB. PgBouncer in transaction mode for Postgres. ProxySQL for MySQL. This lets you have more workers than the DB has connections, by multiplexing.
  3. Disable persistent connections inside long-running workers. They re-use connections per-process, which is the same behaviour you already get for free. The flag is meaningful under fpm, harmful under long-running workers.
  4. In Swoole, use a real coroutine pool. The swoole/db or hyperf/database packages provide one. Sharing a single PDO instance across coroutines without a pool is a race-condition farm.

A benchmark that doesn't account for this measures the runtime in a vacuum. Your production workload doesn't run in a vacuum, it runs inside a connection budget that the DB enforces with prejudice.

What to actually do

Run the methodology, not the headline. Pick the workload that resembles your traffic: JSON, DB queries, a downstream HTTP call. Get the connection pooling right before you trust any number. Compare runtimes head-to-head on identical hardware, identical PHP version, identical OPcache config.

The reason all three runtimes survived is that all three are good. The choice isn't "which is fastest." It's "which ops model do I want to live with at 3am." FrankenPHP gives you one binary. RoadRunner gives you a supervisor that won't surprise you. Swoole gives you a coroutine model that, when you commit to it, opens up workloads the others can't reach.

Whichever you pick, the connection-pool math is the same. Get that wrong and the runtime choice doesn't matter.

Which runtime are you running in production, and what made you pick it: deploy story, raw throughput, or something the benchmark blogs missed?


If this was useful

Once your app outgrows the framework defaults, the patterns that keep it shippable are the same regardless of runtime: clear boundaries, explicit dependencies, infrastructure pushed to the edges. That's what Decoupled PHP is about. The architectural layer your codebase reaches for when "what's the fastest application server" stops being the most interesting question about it.

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)