PSR-15 Middleware Pipelines and PSR-18 HTTP Client Interfaces
Understand the PSR-15 RequestHandlerInterface and MiddlewareInterface contracts and how process($request, $handler): ResponseInterface composes into a middleware pipeline, use PSR-18's ClientInterface for framework-agnostic outgoing HTTP requests, and explain why these small, standardized interfaces enable interoperability between otherwise-unrelated PHP libraries and frameworks.
PSR-7 standardizes what an HTTP request and response look like as objects, but two more PSRs standardize how those objects flow through an application. PSR-15 ('HTTP Server Request Handlers') defines the shape of server-side middleware -- the chain of request-processing steps like authentication, logging, CORS handling, and routing that a request passes through before a final response comes back out. PSR-18 ('HTTP Client') does the same for the outgoing direction, standardizing how application code sends an HTTP request to another service and gets a response back, independent of which underlying HTTP client library (Guzzle, Symfony HttpClient, curl wrappers) is actually doing the network work. Both PSRs are deliberately tiny -- a handful of methods total -- which is exactly what makes them powerful: any library that implements the interface can be swapped in anywhere the interface is expected, with zero knowledge of the concrete class on either side.
PSR-15: RequestHandlerInterface and MiddlewareInterface
PSR-15 defines exactly two interfaces. Psr\Http\Server\RequestHandlerInterface has a single method: handle(ServerRequestInterface $request): ResponseInterface -- something that takes a request and unconditionally produces a response. This is the terminal contract: a request handler is the 'end of the line', typically the application's actual route/controller logic that turns a request into the real response content.
Psr\Http\Server\MiddlewareInterface also has a single method, but a different one: process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface. A middleware receives both the incoming request AND a handler representing 'everything that comes after it in the pipeline', and it is entirely in control of what happens next: it can inspect or modify the request (using PSR-7's with*() methods, since the request is immutable) and then call $handler->handle($request) to continue the pipeline, it can short-circuit and return a response directly without ever calling the handler (for example, an auth middleware rejecting an unauthenticated request with a 401 before the real application logic ever runs), or it can call the handler and then modify the response it gets back on the way out (for example, adding a security header to every response, or timing how long the rest of the pipeline took).
The critical design insight is that MiddlewareInterface's second parameter is itself a RequestHandlerInterface -- so from any single middleware's point of view, 'the rest of the pipeline' looks exactly like one ordinary request handler, regardless of how many more middleware are actually queued up behind it. This is what lets middleware compose into an arbitrarily long chain without any individual middleware needing to know its position in that chain or how many others exist.
• RequestHandlerInterface::handle(ServerRequestInterface $request): ResponseInterface -- terminal: always produces a response • MiddlewareInterface::process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface -- can modify request, call $handler->handle(), modify the response, or short-circuit entirely • A middleware can: pass through unchanged, transform the request before passing it on, transform the response after getting it back, or return early without calling the next handler at all • Because $handler is itself a RequestHandlerInterface, a whole remaining chain of middleware looks like a single handler to whichever middleware is running
How a middleware pipeline actually composes
In practice, a 'pipeline' is built by wrapping middleware around each other so that each one's $handler argument is a small adapter object representing 'call the next middleware in line, or the final route handler if this was the last one'. A typical pipeline runner (often provided by a framework, but simple enough to write by hand) holds an ordered list of MiddlewareInterface instances plus one final RequestHandlerInterface, and produces, for middleware at position N, a RequestHandlerInterface whose handle() method just calls process() on middleware N+1 -- recursively, until the final real handler is reached. The request flows down through the list (each middleware optionally transforming it before calling the next), and the response flows back up through the same list in reverse (each middleware optionally transforming it before returning).
This 'onion' structure (also called the pipeline or chain-of-responsibility pattern) is exactly why middleware order matters: a logging middleware placed before an authentication middleware logs every request including rejected ones, while placed after it only logs requests that passed authentication; an error-handling middleware needs to be wrapped around everything else (placed first/outermost) so that an exception thrown deep in the pipeline still passes back up through it to be converted into a proper error response.
PSR-18: ClientInterface for outgoing HTTP requests
PSR-18 standardizes the other direction: making an outgoing HTTP request to some other service. Psr\Http\Client\ClientInterface defines one method: sendRequest(RequestInterface $request): ResponseInterface, which takes a PSR-7 RequestInterface (built with a PSR-17 request factory) and returns a PSR-7 ResponseInterface, or throws a Psr\Http\Client\ClientExceptionInterface (with more specific subtypes for network errors versus request errors) if the call fails. Notice this method takes and returns the exact same PSR-7 interfaces used for server-side requests and responses -- PSR-18 doesn't invent its own message types, it reuses PSR-7 entirely, which is precisely why a PSR-7 response object built by an HTTP client can, for example, be forwarded almost directly as a proxy's server response with only minimal adjustment.
Because application code depends only on ClientInterface, it never needs to know or care whether the actual HTTP call underneath is made by Guzzle, Symfony's HttpClient (via its PSR-18 adapter), or any other PSR-18-compliant library -- swapping the underlying HTTP client (for performance, for better retry/timeout support, or just a team preference) requires changing which concrete class gets wired up in one place (a service container / dependency injection configuration), with zero changes to any code that calls sendRequest().
Why this enables interoperability: adapters
The whole point of PSR-7, PSR-15, and PSR-18 being separate, narrow, framework-agnostic PSRs is interoperability: a middleware package published on Packagist implementing MiddlewareInterface can be dropped into any PSR-15-compliant application -- a rate-limiting middleware, a CORS middleware, or a request-ID middleware needs to be written exactly once and works everywhere, instead of every framework needing its own bespoke ecosystem of incompatible middleware packages. The same applies to HTTP clients: a library that needs to make an outgoing API call can simply type-hint ClientInterface in its constructor and let whichever application uses it supply any PSR-18 client it prefers, rather than that library hard-depending on (and forcing every consumer to install) one specific HTTP client package.
Where a framework predates these PSRs or has its own historical request/response objects (Symfony's HttpFoundation Request/Response classes, for instance, existed before PSR-7), an adapter package -- typically named something like symfony/psr-http-message-bridge -- provides small converter classes that translate back and forth between the framework's native objects and PSR-7/PSR-15 objects at the boundary. This lets an older or framework-specific codebase still consume the enormous ecosystem of PSR-15 middleware and PSR-18 clients without rewriting its own internals, which is the practical, everyday payoff of these interfaces being standardized in the first place: interoperability is achieved through a thin adapter layer rather than a full rewrite.
PSR-15 and PSR-18 interface signatures
Both PSR-15 interfaces have exactly one method each -- the minimalism is deliberate, so implementing either is a very small commitment for a library author. PSR-18's sendRequest() reuses PSR-7's RequestInterface/ResponseInterface directly rather than defining its own message types.
// PSR-15: Psr\Http\Server\RequestHandlerInterface
interface RequestHandlerInterface {
public function handle(ServerRequestInterface $request): ResponseInterface;
}
// PSR-15: Psr\Http\Server\MiddlewareInterface
interface MiddlewareInterface {
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface;
}
// PSR-18: Psr\Http\Client\ClientInterface
interface ClientInterface {
// Throws Psr\Http\Client\ClientExceptionInterface on failure
public function sendRequest(RequestInterface $request): ResponseInterface;
}
// Typical middleware skeleton
final class ExampleMiddleware implements MiddlewareInterface {
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
// 1. optionally transform $request (immutably) before continuing
$response = $handler->handle($request); // continue the pipeline
// 2. optionally transform $response before returning
return $response;
}
}Code Examples
This middleware calls $handler->handle($request) to let the rest of the pipeline run first, times how long that took, then returns a new response (via withHeader, never mutating the original) with a timing header attached -- a classic 'wrap the response on the way out' middleware.
$response = $handler->handle($request);
RequireApiKeyMiddleware demonstrates both halves of the contract: it can short-circuit (return 401 without ever calling $handler->handle(), so nothing further down the pipeline runs) or, if the key is valid, enrich the request with withAttribute() and continue. The Pipeline class shows the actual composition trick: it implements RequestHandlerInterface itself, so passing '$this' as the $handler argument to the next middleware's process() call recursively represents 'everything remaining in the pipeline' as a single handler.
return $current->process($request, $this);
WeatherApiClient's constructor depends only on ClientInterface and RequestFactoryInterface -- both PSR interfaces, not concrete classes -- so it can be constructed with Guzzle's client, Symfony's, or any other PSR-18-compliant implementation, and the choice can even change between environments (a real client in production, a mock ClientInterface returning canned responses in tests) with zero changes to WeatherApiClient's own code.
private readonly ClientInterface $httpClient,
<?php
require 'vendor/autoload.php';
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Server\MiddlewareInterface;
final class AddResponseTimeHeaderMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$start = microtime(true);
$response = $handler->handle($request); // pass the request further down the pipeline
$elapsedMs = (int) ((microtime(true) - $start) * 1000);
// Transform the RESPONSE on the way back out, without mutating it
return $response->withHeader('X-Response-Time-Ms', (string) $elapsedMs);
}
}<?php
require 'vendor/autoload.php';
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Server\MiddlewareInterface;
use Nyholm\Psr7\Response;
final class RequireApiKeyMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$key = $request->getHeaderLine('X-Api-Key');
if ($key !== 'secret-123') {
// SHORT-CIRCUIT: return a response directly, never calling
// $handler->handle() -- the rest of the pipeline never runs.
return new Response(401, ['Content-Type' => 'text/plain'], 'Unauthorized');
}
// Pass an ENRICHED request forward using withAttribute(), a PSR-7
// with*() call -- later middleware/handlers can read this attribute.
$request = $request->withAttribute('api_key_valid', true);
return $handler->handle($request);
}
}
// A minimal hand-rolled pipeline runner showing HOW composition works:
final class Pipeline implements RequestHandlerInterface
{
/** @param MiddlewareInterface[]
…<?php
require 'vendor/autoload.php';
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface;
final class WeatherApiClient
{
public function __construct(
// Depends ONLY on the PSR-18/PSR-17 interfaces -- not on Guzzle,
// not on Symfony HttpClient, not on any concrete library at all.
private readonly ClientInterface $httpClient,
private readonly RequestFactoryInterface $requestFactory,
private readonly string $apiBaseUrl,
) {}
public function getTemperature(string $city): float
{
$request = $this->requestFactory
->createRequest('GET', $this->apiBaseUrl . '/weather?city=' . urlencode($city))
->withHeader('Accept', 'application/json');
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new RuntimeException('Weather API returned ' . $response->getStatusCode());
}
$data = json_decode((string) $response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR);
return (float) $data['temp_c'];
}
}
// Wiring: ANY PSR-18 cli
…A PSR-15 middleware pipeline (the 'onion')
The request travels inward through the stack, the response travels back outward through the same stack in reverse -- exactly like layers of an onion.
Request --> [ErrorHandling] --> [Auth] --> [Logging] --> [Router/Handler]
| | | |
| | | v
| | | builds Response
| | |<---------------+
| |<-------------+ (may add log fields)
|<-----------------+ (may add auth-derived headers)
v
Response <----------------------------------------------- (may catch & convert errors)
Each middleware's process($request, $handler) can:
- transform $request, then call $handler->handle($request) (continue inward)
- return a Response directly WITHOUT calling $handler (short-circuit)
- transform the Response returned by $handler->handle() (act on the way out)Important Rules
A RequestHandlerInterface always returns a response -- it is the end of the pipeline, never something that delegates further.
A MiddlewareInterface decides itself whether to call $handler->handle() -- forgetting to call it (or conditionally not calling it when you meant to) silently stops the entire rest of the pipeline from running.
Order middleware deliberately: put cross-cutting concerns that must see everything (error handling, request-ID generation) outermost/first, and concerns that should only apply after a check (like logging only authenticated requests) after that check's middleware.
Depend on PSR-18's ClientInterface (and PSR-17 factories) in library code that needs to make HTTP calls, never on a concrete HTTP client class, so consumers can supply whichever implementation they prefer.
When bridging a framework with its own historical request/response types into the PSR-7/15/18 ecosystem, use an existing adapter/bridge package rather than hand-rolling ad hoc conversions at every boundary.
Where this shows up
PSR-15 is the backbone of frameworks like Slim and Mezzio, and Laravel/Symfony both offer PSR-15 bridge layers so third-party middleware from the wider PHP ecosystem can be dropped into an application built primarily around the framework's own native middleware system. PSR-18 is what lets a library like a payment-gateway SDK or a third-party API client declare 'I need some way to send an HTTP request' without forcing every consumer of that library to install Guzzle specifically -- Symfony's HttpClient, Guzzle, and several other packages all ship a PSR-18-compliant adapter, so the application chooses, and the library never notices the difference.
<?php final class LoggingMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { error_log('Request: ' . $request->getUri()); // BUG: never calls $handler->handle($request) -- the pipeline // stops here, the real route handler NEVER runs. return new \Nyholm\Psr7\Response(200); } }
<?php final class LoggingMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { error_log('Request: ' . $request->getUri()); return $handler->handle($request); // continue the pipeline } }
Every middleware that isn't deliberately meant to short-circuit must call $handler->handle($request) and return (or further transform) its result. Returning a hardcoded response instead -- even accidentally, like a leftover placeholder -- silently prevents every middleware and route handler further down the pipeline from ever running, which is a very easy mistake to introduce and a hard one to notice until routes mysteriously stop working.
[multiple-choice] In PSR-15, what is the effect of a MiddlewareInterface::process() implementation that never calls $handler->handle($request)? A. PHP throws a fatal error at runtime because the method must call $handler B. The rest of the middleware pipeline (and the final route handler) never runs -- the middleware has effectively short-circuited the request, intentionally or not C. The request is automatically forwarded anyway by the framework D. It has no effect, since $handler->handle() is called automatically after process() returns
B. The rest of the middleware pipeline (and the final route handler) never runs -- the middleware has effectively short-circuited the request, intentionally or not — Calling $handler->handle($request) is entirely up to the middleware's own code -- nothing calls it automatically. A middleware that returns a response without calling $handler->handle() has deliberately (or accidentally) stopped the pipeline there, which is exactly the mechanism used for legitimate short-circuiting like authentication rejection, but is also a common accidental bug.
[multiple-choice] Why does the second parameter of MiddlewareInterface::process() being typed as RequestHandlerInterface (rather than, say, 'the next MiddlewareInterface') matter for how pipelines compose? A. It doesn't matter -- the type could be anything and pipelines would still work the same way B. It means every middleware sees 'the rest of the pipeline' as a single, uniform handler regardless of how many more middleware are actually queued up behind it, which is what lets pipelines of any length compose without each middleware knowing its position in the chain C. It forces every middleware to be the last one in the pipeline D. RequestHandlerInterface and MiddlewareInterface are actually the same interface under a different name
B. It means every middleware sees 'the rest of the pipeline' as a single, uniform handler regardless of how many more middleware are actually queued up behind it, which is what lets pipelines of any length compose without each middleware knowing its position in the chain — Because RequestHandlerInterface has exactly one method (handle()) with the same shape whether it represents the true final handler or an entire remaining chain of middleware wrapped up as one object, a middleware never needs special-case logic for 'am I the last one' -- it just calls handle() on whatever it was given, and the pipeline-building code is what wires the actual chain together behind that uniform interface.
[multiple-choice] What is the main interoperability benefit of application code depending on PSR-18's ClientInterface instead of a concrete class like GuzzleHttp\Client? A. ClientInterface is faster than any concrete implementation B. It removes the need for network access entirely during testing C. The code can work with ANY PSR-18-compliant HTTP client (Guzzle, Symfony HttpClient, or others) without modification, and a mock ClientInterface can be substituted in tests, since the dependency is on the interface, not a specific library D. PSR-18 automatically retries failed requests, which concrete clients do not
C. The code can work with ANY PSR-18-compliant HTTP client (Guzzle, Symfony HttpClient, or others) without modification, and a mock ClientInterface can be substituted in tests, since the dependency is on the interface, not a specific library — Depending on the interface rather than a concrete class means the actual HTTP client implementation is a swappable detail decided at the point where the object is constructed (often in dependency-injection configuration) -- the class using ClientInterface never needs to change, whether the real client is Guzzle in production or a test double returning canned PSR-7 responses in a unit test.
Write a PSR-15 middleware class CorsMiddleware implementing MiddlewareInterface whose process() method calls $handler->handle($request) to get the response, then returns a new response (via withHeader, not mutation) with an added Access-Control-Allow-Origin: * header. Then write a function makeApiCall(ClientInterface $client, RequestFactoryInterface $factory, string $url): string that builds a GET request via the factory, sends it via $client->sendRequest(), and returns the response body as a string -- using only the PSR-17/PSR-18 interfaces, no concrete HTTP client class.
final class CorsMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); return $response->withHeader('Access-Control-Allow-Origin', '*'); } } function makeApiCall(ClientInterface $client, RequestFactoryInterface $factory, string $url): string { $request = $factory->createRequest('GET', $url); $response = $client->sendRequest($request); return (string) $response->getBody(); }
CorsMiddleware should call $handler->handle($request) FIRST to get $response, then return $response->withHeader(...) -- don't short-circuit here since this middleware needs the real response to annotate. makeApiCall should type-hint ClientInterface and RequestFactoryInterface as parameters, never a concrete class like GuzzleHttp\Client.