spiral/roadrunner
RoadRunner is a high-performance PHP application server and process manager written in Go. It offers HTTP/2/3, gRPC, fCGI, queues, KV storage, and OpenTelemetry via plugins, serving as a powerful alternative to Nginx+FPM.
What this package actually is. spiral/roadrunner is the Composer-side entry point for the RoadRunner binary — a Go application server that owns the listening socket, manages a pool of long-lived PHP worker processes, and speaks a binary protocol (Goridge) to them. The PHP side you write (worker.php, or Laravel Octane) is a loop that receives PSR-7 requests, returns PSR-7 responses, and never exits. That single inversion is the mental model everything else hangs off.
What changed in v3.0.1 (and why it's a small but real update). v3.0.1 is a security-only patch on top of the v3.0.0 major. The binary is now built with Go 1.27.3, which fixes CVE-2026-111943 (GO-2026-6809) in crypto/x509: Go's certificate verifier now rejects known untrusted intermediate certificates during chain verification. Concretely:
.rr.yaml schema, and the middleware ordering semantics from v3.0.0 are all untouched. Your PHP SDKs (spiral/roadrunner-*, laravel/octane) do not need a bump — nothing in composer.lock changes for this patch.3.0.0 as superseded. ./vendor/bin/rr get-binary --version=3.0.1 or ghcr.io/roadrunner-server/roadrunner:3.0.1.amqps://, NSQ with TLS, RR's own TLS listeners, custom Go plugins — can start failing verification where it previously succeeded. Notably, PHP-side outbound HTTPS is unaffected: Laravel's HTTP client uses OpenSSL in the PHP worker, not Go's verifier. So a broken internal CA chain will show up in your job pipeline, not in your Http::get() calls.If you were holding on the 2025.x binary as your upgrade escape hatch, that holding pattern still works — but if you've already moved to v3, go to 3.0.1.
Before you upgrade Octane. Unchanged from v3.0.0: RoadRunner v3 (including 3.0.1) changes the PHP↔Go contract versus the 2025.x series. Verify spiral/roadrunner-* and laravel/octane support v3 before swapping the binary. Safe sequence: upgrade Composer packages first, run against the current binary, then swap the binary and re-run the smoke test. A patch bump within v3 (3.0.0 → 3.0.1) skips all of that — it's a straight binary swap.
Minimal setup in a Laravel app. The day-to-day path is still Octane:
composer require laravel/octane spiral/roadrunner-cli spiral/roadrunner-http
php artisan octane:install --server=roadrunner # publishes config/octane.php + .rr.yaml
./vendor/bin/rr get-binary --version=3.0.1 # or copy from the container image
php artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8080 --workers=4
Hand-rolled non-Laravel worker is unchanged (the PHP loop API is stable; it's the Go side and wire codec that moved in v3.0.0):
<?php
$worker = RoadRunner\Worker::create();
$http = new RoadRunner\Http\PSR7Worker($worker, $f, $f, $f);
while ($req = $http->waitRequest()) {
$http->respond(new Psr7\Response(200, [], 'Hello world!'));
}
Where to look first.
.rr.yaml; it enumerates every renamed/removed key. Still the authority for 3.0.1, since 3.0.1 adds no keys..rr.yaml — pool sizing, middleware, jobs, metrics, RPC are all config. Note version: '3' here is the config schema version, not the binary series.config/octane.php — the per-request flush/warm lists and Octane listeners; still where Laravel-specific bugs get fixed.docs.roadrunner.dev under release/v3 — older pages actively mislead on middleware order and AMQP.pkg.go.dev/vuln/GO-2026-6809 and the issue — five minutes of reading tells you whether your TLS topology is in scope for the x509 change.First realistic use case. Unchanged: RoadRunner in front of an existing Laravel app, 4 workers, nginx still serving /public static assets, benchmark one JSON API endpoint against FPM. If there's no meaningful win there, don't proceed.
A working .rr.yaml for Laravel on v3 (3.0.1 needs no edits).
version: '3'
rpc:
listen: unix:///var/run/rr/rr.sock # socket mode/owner/group configurable in v3
server:
command: "php vendor/bin/roadrunner-worker.php"
env:
APP_ENV: production
on_init:
env:
PHP_OPCACHE_ENABLE: "1" # on_init.env now overrides inherited values
http:
address: "0.0.0.0:8080"
trusted_headers: ["X-Forwarded-For", "X-Real-IP"]
pool:
num_workers: 8
max_jobs: 500
allocate_timeout: 60s
destroy_timeout: 60s
middleware: ["static", "headers", "proxy_ip_parser", "rate_limiter", "zstd", "gzip", "send"]
static:
dir: "public"
forbid: [".php", ".htaccess"]
# v3: url prefixes, metadata/miss cache TTLs, cache size limits
logs:
level: warning
mode: production
metrics:
address: "0.0.0.0:2112"
envfile: .env # optional, but required at boot if configured
Middleware order is the migration trap. v3 evaluates the list left-to-right as the request enters; v5 ran the reverse. The mechanical fix — reverse your existing list — gets you back to parity, but re-derive the order afterwards:
static first, so assets short-circuit before anything else.proxy_ip_parser before rate_limiter, or you rate-limit the load balancer's IP instead of the client's.zstd, then gzip as fallback) after the body exists but before send.New free wins that used to be PHP middleware.
rate_limiter — global, per-IP, or per-header limits with 429 + Retry-After, enforced in Go. Coarse limits on login/webhook/public API here; keep Laravel's throttle for per-user/per-tenant logic.zstd — Zstandard when the client's Accept-Encoding allows, alongside gzip. Check your CDN/LB passes zstd through.headers — move static security/CORS headers out of PHP entirely.proxy_ip_parser + http.trusted_headers — explicit forwarding-header selection. Essential behind nginx/LB.Static middleware got real configuration. url_prefix, metadata/miss cache TTLs, cache memory caps. Two consequences: weak ETags are derived from size+mtime (tests asserting exact ETag values fail), and the cache can serve stale entries after a deploy — wire rr reset static into your deploy script next to rr reset.
Dev loop. --watch with Octane, or rr serve -d. Keep the RPC endpoint reachable so rr workers -i, rr reset, rr stop, rr reset static work from another terminal. rr reset is the reload-without-dropping-the-listener primitive; ./rr reset --rpc unix:///var/run/rr/rr.sock is your deploy hook and /admin/reload endpoint.
Worker sizing. num_workers ≈ CPU cores * 2 for I/O-bound Laravel work, then tune from metrics. Each worker handles one request at a time, so concurrency == worker count. Pair with max_jobs (300–1000) to recycle leaked memory. If you use the dynamic allocator, note the v3 semantic change: dynamic_allocator.max_workers counts workers above the base pool, and acquisition retries after scale-up — effective ceiling is num_workers + max_workers. Audit any autoscaling assumption carried over.
Deploys. Copy the static binary from the image rather than downloading at boot — and pin the patched tag:
FROM ghcr.io/roadrunner-server/roadrunner:3.0.1 AS rr
FROM php:8.3-cli
COPY --from=rr /usr/bin/rr /usr/local/bin/rr
COPY . /app
RUN composer install --no-dev --optimize-autoloader
CMD ["rr", "serve", "-c", ".rr.yaml"]
CI: build, hit /up, deploy, then rr reset && rr reset static. Under systemd, ExecReload=/usr/local/bin/rr reset --rpc unix:///... gives zero-downtime reloads. Need the TCP plugin or anything not in the default container? Embed github.com/roadrunner-server/roadrunner/v3 in a custom Go build — and note your custom build must also be on Go 1.27.3+ to inherit the x509 fix, which is a good reason to keep the stock image unless you need the extra plugin.
Add a TLS smoke test for the x509 change. If anything Go-side dials TLS (amqps://, TLS'd NSQ, a TLS-terminating RR listener, custom plugin egress), add a startup/CI check that the full chain is served — openssl s_client -connect host:5671 -showcerts and confirm the intermediate is present, not just the leaf. This is a five-line script that converts a 3.0.1 rollout from "queue silently dead" into "CI red."
Queues. Keep Laravel queues on php artisan queue:work unless you specifically want RoadRunner's jobs plugin — it's a replacement for the Laravel worker, not a drop-in, and bypasses job middleware, WithoutOverlapping, retryUntil. Two v3 features make it more tempting: named worker pools (the pool header routes to a dedicated PHP pool with its own command and capacity — isolate a slow-report queue from a fast-notification queue), and proper outcome metrics (rr_jobs_jobs_requeue plus separate success/fail/requeue counters) so you can alert on retry storms. NSQ is now a first-class driver.
Observability. Keep metrics on and scrape /metrics — pool saturation, request durations, per-outcome job counters come free. v3 adds gRPC server reflection and a bundled protoreg plugin.
3.0.1's x509 change is invisible until something dials TLS. Go now rejects known untrusted intermediate certificates. Symptom shapes: AMQP/NSQ consumers that reconnect-loop with no PHP-side log, RR failing to start with a TLS listener configured, or a custom plugin's egress dying. Diagnosis order: confirm you actually shipped 3.0.1 (rr --version), confirm the full chain is served by the peer, then confirm the intermediate isn't on Go's reject list. Remember PHP's own OpenSSL calls are a separate trust store — "it works in tinker" proves nothing about the Go side.
Don't let the patch bump drag a major with it. If you pinned spiral/roadrunner-cli or laravel/octane loosely, a composer update while bumping the binary can land you on the v3 SDK line accidentally. Bump the binary tag in the Dockerfile and leave composer.lock alone for this release — the patch requires no PHP-side change.
Middleware reversal is the silent breaker. A v5 .rr.yaml running against a v3 binary doesn't error on order — it boots and applies middleware backwards: rate limits keyed to the wrong IP, compression applied to nothing, static requests reaching PHP. Diff your list against the migration guide; don't assume "reverse it" covered trusted_headers interactions too.
Statefulness is still the bug factory. Every worker is long-lived. Any static property, singleton holding per-user data, once() cache of a Request, or module-level memo becomes cross-user leakage. Register per-request cleanup in config/octane.php's flush array and audit third-party packages that cache state (auth/tenancy/feature-flag packages do). Still the number one reason a migration "works in staging and corrupts data in prod."
Never dd(), dump(), var_dump(), echo, or exit() in a worker. The worker's stdout is the Goridge pipe. Stray output corrupts the frame and you get EOF, unexpected end of stream, or a silent hang. Octane intercepts dd/dump; echo in a Blade partial or a print_r in a legacy library still bites. Send diagnostics to a file or stderr. Debugging an EOF: run php worker.php in a terminal.
MessagePack is gone from RPC. Anything talking to the RPC socket via msgpack (health-check script, internal admin panel, custom sidecar) fails in v3. Switch to JSON and re-verify payload shapes; protobuf, Gob, raw remain.
A configured envfile is now load-bearing. If envfile: is present and the file is missing at startup, the server refuses to boot. If .env is gitignored and baked in later in the build than the binary starts, you get a boot loop that looks like a config error. Env vars also don't propagate automatically from the Go process to PHP workers — use server.env, and prefer server.on_init.env now that it explicitly overrides inherited values.
Requests time out at the Go layer first. allocate_timeout/destroy_timeout and the supervisor's exec_ttl must be longer than PHP's max_execution_time, or RoadRunner kills workers mid-request and you see 500s with no PHP-side log. max_jobs recycling can surface as intermittent failures when a worker dies holding a DB transaction.
Database connections. Persistent connections per worker are the performance win, but they go stale after failover or DB-side wait_timeout. Octane's DisconnectFromDatabases listener covers the common case; for long-lived connections set DB_PERSISTENT=false and lean on the worker lifecycle, or add DB::purge() on RequestTerminated if you see "server has gone away".
HTTP/2 changed shape. Cleartext HTTP/2 in v3 uses prior-knowledge (H2C direct); the HTTP/1.1 Upgrade: h2c handshake is no longer supported. If nginx/Ingress/ALB relies on upgrade headers to reach the backend, that connection silently degrades to HTTP/1.1. Also consider the new PROXY protocol support (v1/v2, explicit trusted proxy addresses) instead of header-based IP forwarding.
Static cache staleness after deploy. Metadata and missing-file caches survive a plain rr reset. Add rr reset static to the pipeline, or a renamed asset 404s and a changed asset serves stale bytes until the TTL expires.
Queue driver configs moved. AMQP static pipelines require config.connection with nested exchange and queue blocks, and config.version is removed — old configs fail validation at boot, not at first job. NATS in v3 preserves stream contents when a pipeline stops unless stream deletion is enabled, so "stop the pipeline and the backlog clears" becomes unbounded disk growth. Review ack_wait and redelivery at the same time.
Uploads and body size. Large uploads buffer through Go and the binary pipe; for 100 MB uploads check the http plugin limits and use the send middleware / x-sendfile for downloads so PHP isn't streaming bytes.
RPC surface is your extension point — and a liability. RPC supports unix sockets with configurable mode/owner/group; use that instead of tcp://0.0.0.0:6001, which is remote code execution via rr stop/rr reset. From PHP, spiral/roadrunner-cli's RPC client lets you trigger reset/stop from application code (e.g. a signed deploy webhook) — keep it on a local socket.
Xdebug off. It serializes and conflates in long-running workers and adds per-request cost. Use only in a throwaway container with num_workers: 1.
Don't assume it's faster — and don't assume any release is free. RoadRunner wins on high-throughput, small-payload, I/O-bound endpoints because you skip per-request bootstrap; for an already-fast CPU-bound app you've added a Go process, a binary protocol, and a class of statefulness bugs for little gain. v3.0.0 added a config-schema break and a wire-codec change; v3.0.1 adds a trust-store behaviour change on the Go side. Keep the binary a pinned dependency (3.0.1, not latest) with .rr.yaml in the same commit, and re-run the TLS smoke test whenever that pin moves.
How can I help you explore Laravel packages today?