mindplay/middleman
PSR-15 middleware dispatcher for PHP that composes middleware into a single request handler. Supports lazy middleware resolution from a PSR-11 container, class-name based middleware, and PSR-7/PSR-15 stacks, making it easy to build clean, layered HTTP applications.
mindplay/middleman is not a Laravel package — it is a ~3-class PSR-15 dispatcher. Install it alongside a PSR-7 implementation and a PSR-17 factory:
composer require mindplay/middleman nyholm/psr7 nyholm/psr7-server
Read the whole thing before using it; there is no more to read:
src/Dispatcher.php — implements RequestHandlerInterface, flattens [middleware…, terminal] into a delegate chain.src/Delegate.php — the RequestHandlerInterface wrapper that calls one middleware with the next delegate.src/MiddlewareResolver.php — the extension point for resolving string/array middleware specifications (used for lazy, container-backed middleware).Mental model:
$dispatcher = new Dispatcher([
$outermostMiddleware, // runs first, sees the response last
$anotherMiddleware,
$terminalHandler, // RequestHandlerInterface (or the last callable)
]);
$psr7Response = $dispatcher->handle($psr7Request);
First use case: run a single third-party PSR-15 middleware (webhook signature check, request ID, CORS) inside a Laravel route without dragging in Slim/Mezzio.
use mindplay\middleman\Dispatcher;
use Nyholm\Psr7\{ServerRequest, Response};
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
Route::post('/webhooks/github', function (Illuminate\Http\Request $request) {
$psr7 = new ServerRequest(
$request->method(),
$request->fullUrl(),
$request->headers->all(),
$request->getContent(),
$request->getProtocolVersion()
);
$dispatcher = new Dispatcher(
[new App\Http\Psr15\GithubSignatureMiddleware(config('services.github.secret'))],
new class implements RequestHandlerInterface {
public function handle(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
{
return new Response(204);
}
}
);
$psr7Response = $dispatcher->handle($psr7);
return response((string) $psr7Response->getBody(), $psr7Response->getStatusCode())
->withHeaders($psr7Response->getHeaders());
});
1. A single Laravel middleware as the PSR-15 bridge. Register one class globally or per route group, keep the PSR-15 list in config, and let the terminal handler call $next($request) so the rest of the Laravel stack still runs:
class Psr15Bridge
{
public function __construct(private array $middleware) {}
public function handle($request, Closure $next)
{
$psr7 = $this->toPsr7($request);
$dispatcher = new Dispatcher(
$this->middleware,
new class($next, $request) implements RequestHandlerInterface {
public function __construct(private Closure $next, private $original) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$response = ($this->next)($this->original);
return new Nyholm\Psr7\Response(
$response->getStatusCode(),
$response->headers->all(),
$response->getContent()
);
}
}
);
$response = $dispatcher->handle($psr7);
return response((string) $response->getBody(), $response->getStatusCode())
->withHeaders($response->getHeaders());
}
}
2. Config-driven, container-resolved stacks. Middleman accepts class strings and [Class::class, 'method'] specs, resolving them lazily through a PSR-11 container plus a MiddlewareResolver. Put the order in config/http.php so ops can reorder without a deploy of code logic:
// AppServiceProvider::boot()
$this->app->bind(Dispatcher::class, fn ($app) => new Dispatcher(
config('http.psr15'), // ['App\Psr15\RequestId', ...]
$app->make(App\Http\Psr15\TerminalHandler::class),
$app // Laravel's container is PSR-11
));
Because resolution is lazy, an expensive middleware (one that opens a DB connection in its constructor) is only constructed if an earlier layer didn't short-circuit.
3. Nested dispatchers as route groups. A Dispatcher is a RequestHandlerInterface, so you can nest one as the terminal of another: outer stack = global concerns (request ID, CORS, timing), inner stack = route-group concerns (webhook auth, payload validation).
4. Standalone sub-app behind a catch-all route. For a Slack/Discord bot, mount a Route::any('/bot/{any?}', ...)->where('any', '.*') and let a terminal handler do the routing inside the dispatcher. This keeps the PSR-15 stack portable — you can lift it into Mezzio later without touching middleware.
5. Keep the boundary honest. PSR-15 side: request IDs, CORS, signature verification, body parsing, rate limiting, timing/logging, third-party league/* middleware. Laravel side: StartSession, VerifyCsrfToken, SubstituteBindings, auth, anything that needs Auth/Session facades. The PSR-15 stack executes inside Laravel's chain, so Laravel's framework-coupled middleware must already have run.
6. Test middleware without booting Laravel. The package's biggest win day-to-day is testability — new Dispatcher([$mw], $terminal) with a Nyholm ServerRequest gives you a fast, framework-free unit test for each middleware.
$middlewarePriority and named aliases (->middleware('auth')) do not apply — you get no priority negotiation.RequestHandlerInterface, or make the last element a callable that returns a response. Every middleware must return $handler->handle($request) (or return a response to short-circuit) — returning null or forgetting the return produces a TypeError deep in Delegate, not a helpful message.$handler and calling it twice will re-run downstream middleware; there is no guard against it.$dispatcher->handle() in try/catch if you want Laravel's exception handler to see framework-native exceptions, or add an outermost PSR-15 error middleware. Also note: a PSR-15 middleware that short-circuits with a response bypasses any Laravel middleware below the bridge (CSRF, auth) — a real security footgun if you mount the bridge above VerifyCsrfToken.(string) $response->getBody() uses __toString, which reads from the current pointer — call $response->getBody()->rewind() first, and use response()->stream() for large or binary payloads rather than buffering.HeaderBag collapses multiple Set-Cookie headers into one comma-joined value; map cookies explicitly with withCookie() if the PSR-15 middleware sets them.getParsedBody() and uploaded files don't survive a hand-rolled ServerRequest constructor. For faithful conversion use Nyholm\Psr7Server\ServerRequestCreator::fromGlobals(). For JSON webhooks, manual construction is fine and cheaper.has() is bound(). If the resolver checks has() before get(), unbound-but-autowirable middleware classes silently fail to resolve. Bind your PSR-15 middleware explicitly in a service provider. Conversely, Laravel's get() auto-wires anything — typos are caught, but accidental construction of a heavy dependency isn't.Dispatcher as a singleton and its middleware store per-request state (parsed payload, auth user, timing), that state bleeds across requests under Octane and even under FPM. Bind transient/scoped, or construct per request.$response->withHeader(...) and $request->withAttribute(...) return new instances. Reassign or you'll silently drop data — a classic PSR-7 bug that middleman won't catch for you.handle(). If you implement MiddlewareInterface yourself, mind the : ResponseInterface declaration or you'll get a fatal signature error.Auth::user(), session(), or csrf_token(). Injecting them makes that middleware non-portable — keep framework-coupled checks on the Laravel side of the bridge.dump() in Dispatcher::handle(), or add a tiny timing middleware at the top of the stack and log to Log::debug() — you have full visibility because the codebase is three files.MiddlewareResolver. Implement it to change how string specs map to instances — tagged container bindings, attribute-based resolution, per-route groups, or cached reflection. This is the only place to add "magic."How can I help you explore Laravel packages today?