- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've seen this method. A controller action that starts by reading a bearer token, checks a rate-limit bucket in Redis, pulls the tenant ID out of a header, opens a database transaction, and only then, forty lines down, calls the thing the endpoint is actually for.
Then the queue worker needs the same business action. So does the CLI backfill command. Now you have three copies of the tenant lookup, two copies of the rate check, and a bug that only exists on the path that forgot one of them.
The auth, the rate limit, the request ID, the tenant resolution: none of that is your business rule. It is HTTP plumbing. PSR-15 gives you a place to put it that is not inside your use case and not copy-pasted across controllers. That place is the middleware pipeline, and it is the outer edge of your application, the same way a Doctrine repository is an outer edge on the way out.
What PSR-15 actually is
Two interfaces. That is the whole standard.
<?php
namespace Psr\Http\Server;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RequestHandlerInterface
{
public function handle(
ServerRequestInterface $request
): ResponseInterface;
}
interface MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface;
}
A RequestHandlerInterface turns a request into a response. A MiddlewareInterface sits in front of a handler: it gets the request, does something, and either calls $handler->handle($request) to pass control inward or short-circuits with its own response.
That is the entire contract. It builds on PSR-7 (the request and response value objects) and nothing else. No framework. Your middleware works the same under Slim, Mezzio, Laminas, or a hand-rolled runner, because none of them own these interfaces.
The pipeline is a stack, not a list
The mental model that trips people up: middleware wraps, it does not run in a line. Each one holds the rest of the pipeline as $handler and decides whether to call it.
<?php
declare(strict_types=1);
namespace App\Http\Kernel;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class Pipeline implements RequestHandlerInterface
{
/** @var MiddlewareInterface[] */
private array $queue;
public function __construct(
array $middleware,
private readonly RequestHandlerInterface $core,
) {
$this->queue = array_values($middleware);
}
public function handle(
ServerRequestInterface $request
): ResponseInterface {
$next = array_shift($this->queue);
if ($next === null) {
return $this->core->handle($request);
}
return $next->process($request, $this);
}
}
Each call to handle pops the next middleware and passes $this as the handler, so the middleware that runs later is always reachable through the one running now. Control flows inward until the core handler produces a response, then unwinds back out through every process method that is still on the stack. A middleware can inspect the response on the way out, not only the request on the way in.
For production, reach for a battle-tested runner rather than the toy above: relay/relay is a minimal PSR-15 dispatcher, and Slim, Mezzio, and Laminas ship their own. The shape is the same in all of them.
An auth middleware that stops at the edge
Authentication is the textbook case. It is HTTP-shaped, it is cross-cutting, and it has no business being inside a use case.
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Application\Auth\TokenVerifier;
use App\Application\Auth\InvalidToken;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final readonly class AuthenticationMiddleware
implements MiddlewareInterface
{
public function __construct(
private TokenVerifier $verifier,
private ResponseFactoryInterface $responses,
) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$header = $request->getHeaderLine('Authorization');
$token = str_starts_with($header, 'Bearer ')
? substr($header, 7)
: '';
try {
$identity = $this->verifier->verify($token);
} catch (InvalidToken) {
return $this->responses
->createResponse(401)
->withHeader('WWW-Authenticate', 'Bearer');
}
$request = $request->withAttribute('identity', $identity);
return $handler->handle($request);
}
}
Two paths out. A bad token returns a 401 and the pipeline never reaches the controller. A good token attaches an Identity value object to the request as an attribute and passes control inward. The controller reads $request->getAttribute('identity') and never parses a header, never knows the word Bearer.
The part that matters for decoupling: TokenVerifier is an application port, not a JWT library. The middleware depends on verify(string): Identity. Whether that is signed JWTs, opaque tokens in Redis, or a call to an auth service lives behind the interface. The HTTP concern (reading the header, choosing the status code) stays in the middleware; the verification policy stays in the application layer where you can test it without a request object.
The controller shrinks to a translator
Once the pipeline handles the cross-cutting work, the inbound HTTP adapter does one job: turn a request into a use-case input, and a use-case output into a response.
<?php
declare(strict_types=1);
namespace App\Http\Controller;
use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final readonly class PlaceOrderController
implements RequestHandlerInterface
{
public function __construct(
private PlaceOrder $placeOrder,
private Responder $responder,
) {}
public function handle(
ServerRequestInterface $request
): ResponseInterface {
$identity = $request->getAttribute('identity');
$body = (array) $request->getParsedBody();
$output = $this->placeOrder->execute(
new PlaceOrderInput(
customerId: $identity->customerId(),
items: $body['items'] ?? [],
currency: $body['currency'] ?? 'EUR',
),
);
return $this->responder->created($output);
}
}
No auth here. No rate limiting. No request ID generation. Those already ran, above this handler, in the pipeline. The controller trusts the identity attribute is present because the auth middleware guarantees it or the request never arrives. That is the contract the pipeline enforces on the way in.
Where each concern belongs
Sort your cross-cutting work by what it actually depends on. HTTP concerns go in middleware. Business concerns go in the use case. The line is sharp once you look for it.
Middleware (HTTP edge) Use case (application core)
------------------------ ----------------------------
Bearer token parsing "a customer can place an order"
Rate limiting per IP/key "an order needs at least one item"
Request ID / correlation ID "charge before you save"
CORS headers "emit OrderPlaced on success"
Content negotiation transaction boundary (decorator)
Gzip / response compression domain event publishing
A rate limit is HTTP: it counts requests against an IP or an API key, both of which are transport identifiers. It belongs in middleware. A rule that a customer on the free plan can place at most ten orders a day is a business rule: it depends on the customer, the plan, and the domain calendar. It belongs in the use case. The two look similar and live on opposite sides of the boundary.
Get this wrong and the symptoms are familiar. Business rules leak into middleware, so the CLI path skips them. Or HTTP concerns leak into the use case, so it can no longer run without a request object, and your unit tests start constructing fake PSR-7 requests to test order placement.
A request-ID middleware, because observability is an edge concern too
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Application\Context\RequestContext;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Symfony\Component\Uid\Uuid;
final readonly class RequestIdMiddleware
implements MiddlewareInterface
{
public function __construct(
private RequestContext $context,
) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$id = $request->getHeaderLine('X-Request-Id')
?: Uuid::v7()->toRfc4122();
$this->context->setRequestId($id);
$request = $request->withAttribute('request_id', $id);
$response = $handler->handle($request);
return $response->withHeader('X-Request-Id', $id);
}
}
This one touches the request on the way in and the response on the way out. It reads an incoming correlation ID or mints a fresh UUIDv7, stashes it where your logger can read it, and echoes it back on the response header so a caller can trace the request across services. The use case logs against RequestContext without ever knowing an HTTP header carried the ID. If tomorrow the same use case runs from a queue message, a different adapter sets the context from the message metadata, and nothing in the core changes.
Ordering is a deliberate choice
The pipeline order is part of your architecture. Put request-ID first so everything downstream, including error responses, carries the correlation ID. Put rate limiting before auth if you want to throttle unauthenticated floods; put it after if your limits are per-account. Put the error-handling middleware outermost so it can catch anything thrown deeper in.
$pipeline = new Pipeline(
middleware: [
$container->get(ErrorHandlerMiddleware::class),
$container->get(RequestIdMiddleware::class),
$container->get(RateLimitMiddleware::class),
$container->get(AuthenticationMiddleware::class),
$container->get(ContentNegotiationMiddleware::class),
],
core: $router,
);
$response = $pipeline->handle($request);
Read it top to bottom and you are reading the exact order a request passes through on its way in, and the reverse order it unwinds on the way out. The composition root owns this list. It is the one place that knows the full HTTP edge, the same way a container is the one place that knows which adapter is bound to which port. When a new engineer asks "what happens to a request before it hits a controller?" you point at these five lines.
The payoff
The point of pushing all of this into the pipeline is not tidiness. It is that your use cases stop importing PSR-7. PlaceOrder takes a PlaceOrderInput DTO and returns a PlaceOrderOutput. It never sees a header, a status code, or a middleware. You can call it from a controller, a console command, a queue consumer, or a test, and it behaves the same because the HTTP-shaped work was handled by an adapter that the core never imports.
That is the whole hexagonal idea applied to the inbound edge. Middleware and the request handler are the driving adapter. PSR-15 just happens to give you a standard, framework-agnostic shape for it, so the edge you build under Slim today survives the move to Mezzio next year with the pipeline list barely changing.
Keeping the HTTP edge in the pipeline and the business rules in the use case is one of the seams Decoupled PHP spends the most time on, because it is the seam that most often rots first. When auth, rate limits, and request context live in middleware, and the domain action lives in a use case that never imports PSR-7, you get an application where the web framework is one adapter among several, not the thing your business logic is welded to.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)