roadrunner/http
RoadRunner HTTP worker for PHP: serve PSR-7/PSR-15 requests in long-running RoadRunner workers, with request/response bridging, middleware support, and fast, production-ready HTTP handling for Laravel and other PHP apps.
roadrunner/http is the worker-side PSR-7 client. It is not a framework adapter and it is not an application server — it is the small piece of PHP that sits inside a long-lived RoadRunner worker, reads a ServerRequestInterface from stdin, and writes a ResponseInterface back. If you use Laravel Octane this is normally hidden behind spiral/roadrunner-laravel; you touch it directly when you build a custom worker, a microservice, a queue/HTTP hybrid, or when you need to debug why Octane's RoadRunner driver behaves the way it does.
1. Install the client plus a PSR-7 implementation. The package deliberately ships no PSR-7 classes:
composer require roadrunner/http nyholm/psr7
# or guzzlehttp/psr7 if you prefer
You also need the RoadRunner binary (./vendor/bin/rr get-binary or a system install) and the .rr.yaml server config.
2. Write the smallest possible worker. Everything else builds on this loop:
<?php
// worker.php
require __DIR__ . '/vendor/autoload.php';
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker;
$factory = new Psr17Factory();
$worker = new PSR7Worker(
Worker::create(),
$factory, // RequestFactoryInterface
$factory, // StreamFactoryInterface
$factory // UploadedFileFactoryInterface
);
while ($request = $worker->waitRequest()) {
$worker->respond(new Response(
200,
['Content-Type' => 'application/json'],
json_encode(['path' => $request->getUri()->getPath()])
));
}
3. Point RoadRunner at it.
# .rr.yaml
version: '3'
server:
command: "php worker.php"
http:
address: 0.0.0.0:8080
pool:
num_workers: 4
max_jobs: 500
./vendor/bin/rr serve
curl http://localhost:8080/anything
Where to look first. Read three things in this order: (a) the PSR7Worker constructor and the waitRequest / respond pair — that is the whole contract; (b) Spiral\RoadRunner\Worker in the worker dependency, which handles the Goridge transport and error() reporting; (c) the .rr.yaml http section, because half of the "the package is broken" bugs are actually pool or middleware configuration.
First real use case. A JSON API or internal service where bootstrapping (container wiring, config parsing, autoloading) is the dominant cost. Because the process survives across requests, you pay bootstrap once and then only per-request work. That is the entire value proposition — structure your code so that everything expensive happens before the loop.
Canonical request/response loop. waitRequest() blocks on stdin and returns ?ServerRequestInterface; null means the server closed the pipe (shutdown, max_jobs reached, or a reload). Always let the loop fall through on null rather than throwing — that is the graceful-shutdown signal.
PSR-15 middleware as the backbone. The clean pattern is to keep the worker dumb and push logic into a dispatcher:
$dispatcher = new class($middleware) implements RequestHandlerInterface { /* ... */ };
while ($request = $worker->waitRequest()) {
try {
$response = $dispatcher->handle($request);
} catch (Throwable $e) {
$response = new Response(500, ['Content-Type' => 'text/plain'], 'Internal error');
$worker->getWorker()->error((string) $e); // surfaced in rr logs
}
$worker->respond($response);
}
Error handling that survives the request. An uncaught exception terminates the worker; RoadRunner respawns it, but you lose all warm state and pay bootstrap again. Catch at the loop boundary, log via WorkerInterface::error() (it goes to stderr and the supervisor log), and return a real response. Reserve re-throwing for genuinely unrecoverable conditions.
File uploads. RoadRunner parses multipart bodies in Go and hands them over as structured data. The UploadedFileFactoryInterface is what turns them into UploadedFileInterface objects — omit it in the constructor and file-upload requests will fail. Nyholm\Psr7\Factory\Psr17Factory implements all four factory interfaces, which is why it is the default recommendation.
Framework bridging. For Symfony, use runtime/symfony (or the older symfony/psr-http-message-bridge + HttpKernel conversion) and hand the PSR-7 request off inside the loop. For Laravel, spiral/roadrunner-laravel does the same job; do not try to bolt roadrunner/http onto Laravel manually unless you are prepared to manage the container, session, and Application reset lifecycle yourself.
Streaming and large responses. respond() drains the response body stream into the Goridge frame. That means the whole body is buffered in the worker — it is not a streaming transport. For large downloads, prefer RoadRunner's static middleware or the S3/fileserver plugins over building a huge Stream in PHP.
Route dispatch and bootstrapping. Typical layout: build the router, DI container, and DB connection before while, then per request only resolve the handler, run middleware, and reset mutable state. Put the reset in a finally so an exception does not leak state into the next request (see gotchas).
Health and readiness. A GET /health short-circuit at the top of the loop is cheap and makes Kubernetes probes trivial. Remember it still consumes a worker slot; with num_workers: 4 a slow request blocks a quarter of your capacity.
Testing. Because the worker is just a loop over a PSR-7 pair, unit-test your dispatcher with nyholm/psr7 requests directly and never instantiate PSR7Worker in tests. Integration-test the whole thing by booting rr serve against a fixture worker in CI.
API drift across major versions. Earlier generations exposed waitRequest(): bool followed by getRequest(). Modern versions return the request from waitRequest() (and it can be null). Copy-pasting an old tutorial is the single most common source of "call to a member function on null" or "method does not exist." Pin the version and read the changelog before upgrading across majors.
null is not an error. Null from waitRequest() is the normal shutdown path. Wrapping the loop in a try/finally that logs "worker crashed" on null generates constant false alarms during every deploy.
Broken pipe on respond(). If the client disconnects mid-request, or RoadRunner has already torn down the worker (e.g. max_jobs hit, or a timeout from the http middleware), the write can fail. Wrap respond() and treat write failures as "abandon this request," not "crash the worker."
Memory leaks are your problem, not the package's. Long-lived workers keep everything: static properties, Eloquent's connection and model event listeners, container singletons holding request data, $_SERVER leftovers from libraries that never expected reuse. Audit for state that assumes a fresh process. In practice max_jobs: 500 is a pragmatic safety net, not a substitute for finding the leak.
Header quirks. Do not set Content-Length — RoadRunner derives it. Multi-value headers must use the PSR-7 array form. Header casing is normalised; if downstream tooling is case-sensitive you will see differences from PHP-FPM. Transfer-Encoding: chunked is not something the worker controls.
$_SERVER, $_GET, and friends are stale. RoadRunner does not re-populate superglobals. Anything reading $_SERVER['REQUEST_URI'] (old analytics SDKs, some error handlers) will report the first request forever. Read from the PSR-7 request instead, and consider explicitly clearing superglobals per iteration if third-party code depends on them.
RR_* environment variables drive the transport. Worker::create() reads relay/RPC settings from the environment. Running php worker.php directly in a terminal without rr serve will hang on stdin — that is expected, not a bug. For offline debugging, instantiate a fake WorkerInterface instead.
Debugging loop. rr serve -v (or -vv) shows worker stderr and restart reasons; WorkerInterface::error() output appears there. Add a temporary error_log() inside the loop to trace how many requests a single process handled — this reveals both leaks and whether num_workers is actually what you think it is.
Configuration coupling. num_workers must be tuned against your DB pool size and CPU count, and PHP's own memory_limit applies per worker process, not per request. With num_workers: 8 you have eight resident PHP processes; size the machine accordingly.
Extension points worth knowing. PSR7Worker::getWorker() exposes the underlying WorkerInterface for logging and diagnostics. Decorating that interface is the clean way to add structured logging or metrics without forking the package. Custom PSR-7 factories (a logging decorator around Psr17Factory) let you inspect requests and responses centrally — genuinely useful for debugging in a stack where dd() is impossible.
Middleware precedence. RoadRunner's http.middleware (gzip, static, headers, sendfile) runs in Go before the request ever reaches your PSR-7 worker. If a response looks mangled before your code runs, suspect that ordering first — your PHP-side middleware cannot see or undo those transformations.
Do not use it for queue workers or gRPC. This package is HTTP-specific. The same RoadRunner binary has jobs and grpc plugins served by different client packages; mixing them into one PSR7Worker loop does not work.
How can I help you explore Laravel packages today?