innmind/http-server
A small HTTP server for PHP built on Innmind's HTTP abstractions. It wires PSR-7 requests and PSR-15-style middleware into an immutable application, handles the request lifecycle, and emits responses, giving you a framework-free foundation for serving APIs and web apps.
innmind/http-server is not a framework — it's the edge layer that connects a PSR-7 request producer to a handler and a response emitter. You bring your own router, container and middleware; the package gives you a small set of interfaces plus a Main bootstrap that ties them together.
composer require innmind/http-server
Before writing anything, open vendor/innmind/http-server/composer.json. Innmind packages track the latest PHP release aggressively, and releases are versioned by date (the current one is dated 2026-08-16). Pin an exact version in composer.json rather than a caret range — with zero dependents there is no ecosystem pressure to keep BC, and a composer update can silently raise your minimum PHP.
| File | Why |
|---|---|
src/Main.php |
The entry point. Its of()/__invoke() signature tells you the whole composition: process → server → handler → sender. |
src/Server.php |
The interface you'll implement to plug in a runtime (built-in SAPI, RoadRunner, FrankenPHP, Swoole). |
src/RequestHandler.php |
A single method, __invoke(ServerRequest): Response. This is where 95% of your code lives. |
src/ResponseSender.php |
How bytes reach the client. Swap this to add gzip, streaming, or test doubles. |
The tests/ directory is your real documentation — Innmind ships heavy test suites, and the fixtures show the intended wiring better than the README does. Also skim innmind/http (the PSR-7 implementation you'll be constructing) and innmind/immutable (Maybe, Either, Attempt) since handlers return those constantly.
Start by getting one invokable handler to return a 200 with a JSON body, wired to the built-in server. That proves the loop end to end.
<?php
declare(strict_types=1);
namespace App\Http;
use Innmind\Http\Message\ServerRequest;
use Innmind\Http\Message\Response;
use Innmind\Http\Message\Response\Status;
use Innmind\Http\Message\Stream;
use Innmind\HttpServer\RequestHandler;
final class HealthCheck implements RequestHandler
{
public function __invoke(ServerRequest $request): Response
{
return Response::of(
Status::ok,
$request->protocolVersion(),
null,
Stream::ofContent('{"status":"ok"}'),
);
}
}
Then compose it in your front controller:
// public/index.php
use Innmind\HttpServer\Main;
$main = Main::of(/* CurrentProcess / env */);
$main(
$operatingSystem,
$server, // Innmind\HttpServer\Server
$handler, // your RequestHandler
$responseSender, // Innmind\HttpServer\ResponseSender
);
The exact argument list of Main::of() changes between releases — treat the method signature in src/Main.php as the source of truth and copy the shape from the newest test case. Once the health check responds, you have a working harness for everything else.
RequestHandler is not PSR-15: there is no handle() and no MiddlewareInterface. Composition is done by decorator classes that implement the same interface and wrap an inner handler. This is the single most important pattern in this package.
final class CatchErrors implements RequestHandler
{
public function __construct(private RequestHandler $inner) {}
public function __invoke(ServerRequest $request): Response
{
try {
return ($this->inner)($request);
} catch (\Throwable $e) {
// log, then return a deliberately opaque 500
return Response::of(Status::internalServerError, $request->protocolVersion());
}
}
}
Build your stack as a pipeline, outermost first:
CatchErrors
└─ LogRequests
└─ AddRequestId
└─ Router
└─ Controller
Write the wiring once in a factory function so the order is visible and testable:
function handler(Container $c): RequestHandler
{
return new CatchErrors(
new LogRequests(
new AddRequestId(
new Router($c->get(RouteTable::class)),
$c->get(RequestIdGenerator::class),
),
$c->get(LoggerInterface::class),
),
);
}
innmind/http gives you real value objects, so route on them rather than on raw strings.
final class Router implements RequestHandler
{
public function __invoke(ServerRequest $request): Response
{
$path = $request->url()->path()->toString();
$method = $request->method()->toString();
return match (true) {
$method === 'GET' && $path === '/health' => ($this->health)($request),
$method === 'GET' && $path === '/users' => ($this->listUsers)($request),
$method === 'POST' && $path === '/users' => ($this->createUser)($request),
default => Response::of(Status::notFound, $request->protocolVersion()),
};
}
}
For anything beyond a dozen routes, generate a trie or a regex table at boot rather than match(true) per request — but keep the RequestHandler boundary so you can swap it out.
Maybe/Either, don't throwThe Innmind style is to keep failures in the return type. Parse the body into a string once, then hand it to innmind/validation or your own decoder that returns Either<Error, Command>:
public function __invoke(ServerRequest $request): Response
{
$payload = $request->body()->toString();
return $this->decode($payload) // Either<ValidationError, CreateUser>
->map(fn(CreateUser $cmd) => $this->useCase->execute($cmd))
->match(
fn(Response $r) => $r,
fn(ValidationError $e) => $this->problem(Status::badRequest, $e),
);
}
This keeps every handler branch-free at the top level and makes the error responses uniform.
Every handler constructing Response::of(...) by hand gets noisy fast. Add a tiny factory in your app (not in the vendor tree):
final class Json
{
public static function of(Status $status, array $data, ProtocolVersion $v): Response
{
return Response::of(
$status,
$v,
Headers::of(new Header('content-type', 'application/json')),
Stream::ofContent(json_encode($data, JSON_THROW_ON_ERROR)),
);
}
}
Because a handler is a pure ServerRequest -> Response function, you never need a running server for unit tests:
$response = (new Router(...))($serverRequest);
$this->assertSame('200', $response->status()->toString());
$this->assertSame('{"status":"ok"}', $response->body()->toString());
Build ServerRequest instances directly from innmind/http's request factory in your test bootstrap, and keep a fixture builder so tests read like aRequest('POST', '/users', '{"name":"x"}').
To test the Main wiring itself, implement the two tiny interfaces with fakes: a Server that invokes the handler once with a canned request, and a ResponseSender that records what it was given. That catches argument-order mistakes in Main::of() without spinning up a process.
If you plug into RoadRunner/Swoole/FrankenPHP, the PHP process outlives the request. Handlers must be constructed at boot and hold no per-request state. Anything request-scoped (auth user, request ID, logger with context) travels as a parameter or a fresh decorator instance, never as a mutable property.
PSR-7 immutability bites everyone once. withHeader(), withBody(), withStatus() all return new instances. Forgetting to reassign is the most common bug:
$response->withHeader('x-request-id', $id); // silently discarded
$response = $response->withHeader('x-request-id', $id); // correct
Not PSR-15. You cannot drop in middlewares/* or Slim/Laminas middleware. If you need one, write a small adapter wrapping a PSR-15 RequestHandlerInterface and translating ServerRequest in/out. Budget for this if you're migrating an existing app.
Uncaught exceptions depend on your wiring. Nothing in the package guarantees a 500 for you. If you forget the outermost CatchErrors decorator, a TypeError in a controller will either kill the process or leak a stack trace to the client depending on the Server implementation. Make the catch-all decorator the first thing you write, not the last.
Read the request body exactly once. Streams in this ecosystem are single-pass readables. Convert to a string at the top of the handler and pass the string around; passing the stream into two layers will give you an empty second read.
Never echo or var_dump inside a handler. Output goes to the process stdout, bypassing the ResponseSender, and corrupts the response (or, under FastCGI, produces "headers already sent"). For debugging, add a decorator that serialises the request/response to error_log() or a file, and remove it once the issue is fixed.
Content-Length and bodyless statuses. 204, 304 and responses to HEAD must have no body. The emitter is not necessarily smart about this — construct these responses without a stream rather than relying on the sender to strip it.
Headers: withHeader replaces, withAddedHeader appends. Multi-value headers (set-cookie, vary) need the latter, or you'll silently drop all but the last Set-Cookie.
Your own Server implementation is the extension point for anything unusual. If you need graceful shutdown on SIGTERM, connection draining, or access to the underlying socket, implement Server and keep the handler untouched. Same for ResponseSender — it's where streaming/chunked responses or gzip belong.
Zero stars, zero dependents means zero Stack Overflow. There is no community to ask. The compensating factor is that Innmind codebases are unusually well-tested and strictly typed; the tests/ folder and the type signatures are the documentation. Run git log --oneline vendor/... after an upgrade to see what the release actually changed — release notes are minimal.
Static analysis pays for itself here. The interfaces are precise, so Psalm or PHPStan at a high level will catch the PSR-7 misuses above (discarded immutable results, wrong protocol-version argument order) that PHP's runtime won't. Add psalm/psalm as a dev dependency and run it in CI from day one.
Watch the protocol version argument. Nearly every Response::of() call needs $request->protocolVersion() as the second argument; it's easy to hardcode '1.1' in a helper and then get confusing behaviour when a client speaks HTTP/2 or HTTP/1.0. Always thread the request's version through.
How can I help you explore Laravel packages today?