laminas/laminas-httphandlerrunner
Laminas HTTP Handler Runner executes PSR-7 request handlers and PSR-15 middleware, emitting generated responses to the client in traditional PHP server environments.
Laravel does not speak PSR-7 or PSR-15 — it uses Symfony's HttpFoundation and its own middleware pipeline. So day-to-day, this package is not a "drop into composer.json and start using" dependency. It is the tiny piece of glue you reach for when you have something outside Laravel's pipeline that you need to execute and emit: a PSR-15 app (Mezzio/Slim-style stack), a third-party PSR-15 handler you want to reuse, or a PSR-7 response builder whose output you have to actually send.
Minimal setup:
composer require laminas/laminas-httphandlerrunner
composer require laminas/laminas-diactoros # any PSR-7 impl works; Diactoros pairs naturally
Everything in the package is one of three things:
Laminas\HttpHandlerRunner\RequestHandlerRunner (final, implements RequestHandlerRunnerInterface) — marshals a ServerRequestInterface, calls handle() on a RequestHandlerInterface, catches Throwable and delegates to an error-response generator, then hands the response to an emitter. run(): void.Emitter\SapiEmitter and Emitter\SapiStreamEmitter — implement EmitterInterface::emit(ResponseInterface $response): bool using header()/echo against the PHP SAPI.EmitterException, Exception\ExceptionInterface) for "you cannot emit any more".Look first at docs/book/runner.md and docs/book/emitters.md, then src/RequestHandlerRunner.php — it is a handful of lines and reading it will save you an hour.
Your first realistic use case is a dedicated front controller, not a route:
// public/psr.php — a separate entrypoint, NOT Laravel's public/index.php
use Laminas\HttpHandlerRunner\RequestHandlerRunner;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
// ... RequestHandlerRunner wiring from docs/book/runner.md
$runner = new RequestHandlerRunner(
$handler, // your PSR-15 RequestHandlerInterface
new SapiEmitter(),
fn () => /* ServerRequest factory, e.g. ServerRequestFactory::fromGlobals() */,
fn (\Throwable $e) => /* 500 ResponseInterface, and log it */,
);
$runner->run(); // headers + body go straight out; nothing after this is guaranteed
Inside Laravel, the emitter half of this package is what you actually want — and usually you want to reimplement it rather than call it (see below).
1. Bridge the request/response, don't emit. The correct Laravel integration is: take the Illuminate\Http\Request, convert it to PSR-7 (symfony/psr-http-message-bridge + nyholm/psr7 or laminas-diactoros), run your PSR-15 stack, convert the PSR-7 response back with HttpFoundationFactory, and return that from your controller or middleware. Never call $runner->run() from inside a Laravel request — it bypasses Response::send(), terminable middleware, session persistence, octane, and makes the route untestable.
$psrRequest = (new Psr17Factory())->createRequest($laravelRequest); // or the bridge
$psrResponse = $psrHandler->handle($psrRequest); // plain PSR-15 handle()
return $this->httpFoundationFactory->createResponse($psrResponse); // back into Symfony
2. Wrap PSR-15 middleware as Laravel middleware. A small middleware that owns a MiddlewareInterface/RequestHandlerInterface chain and converts both directions lets you reuse third-party PSR-15 components (CORS, ETag, problem+json, rate limiters) as normal Laravel middleware. Keep the conversion at the edges and the chain pure PSR-7 internally.
3. Bind the runner for testability. RequestHandlerRunner is final. Type-hint RequestHandlerRunnerInterface in your classes and bind the concrete runner in an AppServiceProvider. This also lets you swap in a fake emitter.
4. Custom emitter as a spy. For tests (Pest/PHPUnit) and for CLI/queue contexts, implement EmitterInterface yourself:
final class CollectingEmitter implements EmitterInterface
{
public array $emitted = [];
public function emit(ResponseInterface $response): bool
{
$this->emitted[] = $response;
return true;
}
}
This is the single most valuable pattern in the package — it makes "what did my handler actually return?" assertable without headers ever being sent.
5. Streaming. SapiStreamEmitter (constructor takes a max buffer length, default ~8 KB) writes the body in chunks, flushes after headers, and is the right choice for large downloads or long-running output at a standalone entrypoint. Inside Laravel, prefer response()->streamDownload() / response()->stream() — same effect, native lifecycle.
6. Error responses belong to you. The error-response generator callable receives the caught Throwable. Route it to Laravel's logger (report($e)) and return a JSON 500 so failures in a bridged PSR-15 stack look like the rest of your API.
headers_sent() is fatal. SapiEmitter throws EmitterException::forHeadersSent($file, $line) if anything already wrote output — a BOM, a stray newline after ?>, a var_dump, a debug toolbar that flushed early, or Laravel having already sent the response. The exception helpfully tells you which file and line tripped it; read that first.EmitterException::forOutputBuffering(). PHPUnit, ob_start() in a package, and some FPM configs keep a buffer open. In tests, never use a SAPI emitter — use the collecting emitter above.RequestHandlerRunner constructor signatures differ (2.x hardened types, added RequestHandlerRunnerInterface, and marked the class final). Copy-pasting a bootstrap from a blog post or an old Zend Framework tutorial is the #1 source of TypeErrors here. Trust docs/book/runner.md for your installed major.psr/http-message v2. If another package pins a v1-only PSR-7 implementation, you get interface-mismatch fatals deep in the type system. Pin your implementation deliberately (laminas-diactoros v3 or nyholm/psr7 v1.8+).SapiEmitter only receives a ResponseInterface; it does not know the request method, so a HEAD request will still get a body written. Handle HEAD/204/304 body suppression before emitting.Content-Length. Rewind or re-create the stream before emitting.Content-Length + compression. If zlib.output_compression is on, a Content-Length set by your response will disagree with the bytes actually sent. Strip it, or let the SAPI handle it.run() twice (e.g. a retry loop) throws on the second call because headers are already out.@final on the horizon. Classes shipped here are marked soft @final in preparation for a 3.0. Compose around EmitterInterface and RequestHandlerRunnerInterface; do not extend SapiEmitter or RequestHandlerRunner.Laminas\HttpHandlerRunner\ConfigProvider exists for Mezzio/container-driven wiring. In Laravel you'll ignore it and write your own binding — but it's a good reference for how the pieces are meant to be assembled.How can I help you explore Laravel packages today?