In PSR-15, what do MiddlewareInterface::process() and RequestHandlerInterface::handle() declare, and how does a rate-limiting middleware reject or delegate a request?
answer
- Psr\Http\Server namespace
- handle(ServerRequestInterface): ResponseInterface
- process(request, handler): ResponseInterface
- single pass: no response argument
- PSR-17 factory for the 429 response
basics
~10 sPSR-15 declares handle(ServerRequestInterface $request): ResponseInterface and process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface. A rate limiter returns its own 429 response to reject, or calls $handler->handle($request) and returns that response.
solid answer
~40 sPSR-15 defines two interfaces in `Psr\Http\Server`. `RequestHandlerInterface::handle(ServerRequestInterface $request): ResponseInterface` turns a request into a response. `MiddlewareInterface::process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface` either produces a response itself or delegates by calling `$handler->handle($request)`. A rate-limiting middleware checks the caller's bucket: when it is empty it returns a 429 built through an injected PSR-17 `ResponseFactoryInterface`, with a `Retry-After` header, without calling the handler; otherwise it delegates and can add rate-limit headers to the response it gets back. PSR-15 chose this single-pass signature over the older double-pass `fn($request, $response, $next)` style, because a pre-made response passed in has no guarantee of a usable state and a `callable` cannot be strictly typed.
code
php · 31 lines<?php
declare(strict_types=1);
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 class RateLimitMiddleware implements MiddlewareInterface
{
public function __construct(
private RateLimiter $limiter,
private ResponseFactoryInterface $responses,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$key = $request->getAttribute('client_id')
?? ($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown');
$result = $this->limiter->consume($key);
if (!$result->allowed) {
return $this->responses->createResponse(429)
->withHeader('Retry-After', (string) $result->retryAfterSeconds);
}
return $handler->handle($request)
->withHeader('X-RateLimit-Remaining', (string) $result->remaining);
}
}go deeper
Recall the two signatures: handle() takes a server request and returns a response; process() takes a server request and a handler and returns a response.
Explain how a middleware short-circuits by returning its own response or delegates by calling handle(), and why PSR-15 chose single pass over double pass.
Write reusable middleware that depends only on PSR-7, PSR-15 and PSR-17 interfaces, returns a response on every path, and never runs the handler twice.
Decide whether cross-cutting concerns such as rate limiting belong in portable PSR-15 middleware or in the edge proxy, weighing per-account knowledge against load that never reaches PHP.
## The two interfaces **PSR-15** standardises server-side request processing on top of PSR-7 messages. It has exactly two interfaces, both in the `Psr\Http\Server` namespace: ```php interface RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface; } interface MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; } ``` - A **request handler** receives a server request and must return a response. It may throw if it cannot produce one; PSR-15 does not define the exception type. - A **middleware** receives the request and a handler that represents everything after it. It may return a response it creates itself, or delegate to `$handler->handle()` and return, or modify, what comes back. Both work on `ServerRequestInterface`, not the plain `RequestInterface`: PSR-15 is only about requests arriving at a server. ## Rejecting or delegating: the rate limiter A framework-agnostic rate limiter is a good fit for `MiddlewareInterface`, because it has to decide before the application runs. 1. **Identify the caller**: read an attribute set by earlier authentication, such as `client_id`, or fall back to the remote address in `getServerParams()`. 2. **Consume a token** from that caller's bucket in whatever store the package abstracts. 3. **Reject**: if the bucket is empty, build a response with status 429 and a `Retry-After` header and return it. The handler is never called, so no controller, database query or template runs. 4. **Delegate**: otherwise call `$handler->handle($request)` and return the response, optionally adding `X-RateLimit-Remaining` with `withHeader()`. Returning without calling the handler is called **short-circuiting**. How a pipeline orders its middleware and what that means for other components is a framework design question; PSR-15 itself only fixes the two method signatures. ## Creating the 429 without an implementation dependency The middleware needs a response object, but naming a concrete class would tie the package to one PSR-7 implementation. PSR-15 recommends that middleware compose either a response prototype or a factory. The standard factory is **PSR-17**'s `ResponseFactoryInterface`, whose `createResponse(int $code = 200, string $reasonPhrase = '')` returns a `ResponseInterface`. Inject it through the constructor and call `createResponse(429)`. ## Single pass versus double pass Before PSR-15, many PHP projects used the **double-pass** signature, borrowed from another ecosystem: `fn($request, $response, $next): $response`, a callable receiving a pre-made response. PSR-15's meta document explains why it chose the **single-pass** form instead: | Issue | Double pass | PSR-15 single pass | |---|---|---| | Response state | an empty response passed in has no guarantee of being usable; earlier code may have written to its body | the component that returns a response creates it | | Typing | `$next` is a `callable`, which cannot be strictly typed | `RequestHandlerInterface` is a real type | | Creating messages without an implementation | the passed-in response served as a prototype | PSR-17 factories fill that role | The middleware interface also avoids `__invoke()`, so existing double-pass middleware could implement it alongside its old signature for forward compatibility, and `process()` was picked as a name not already in common use, so classes built around `__invoke`, `handle` or `dispatch` could adopt it too. ## Handling errors PSR-15 recommends that an application include a middleware that catches exceptions and converts them into responses, and that it be the first one executed so that every request gets a response. A rate limiter should therefore not catch everything itself; it rejects over-limit traffic with a normal 429 and lets unexpected failures reach that error middleware. ## What to check in a PSR-15 middleware review - The method returns a `ResponseInterface` on every path. - Any `withAttribute()` result is the request passed to `handle()`. - Responses are built through an injected PSR-17 factory, not `new` on a vendor class. - The handler is called at most once; calling it twice runs the rest of the application twice. ## Testing a PSR-15 middleware Because the next step is just a `RequestHandlerInterface`, a test can pass a tiny handler that records whether it was called and returns a fixed 200 response. Two tests then cover the rate limiter: 1. with the limiter reporting an empty bucket, `process()` returns 429 with `Retry-After`, and the recording handler was **not** called; 2. with tokens left, the handler was called once and the returned response carries `X-RateLimit-Remaining`. No framework, router or web server is needed, which is the practical payoff of coding against the two interfaces.
- Why does PSR-15 recommend injecting a PSR-17 factory into middleware that creates responses?Writing `new Response()` ties the middleware to one PSR-7 implementation. Depending on `ResponseFactoryInterface` lets the application wire in whichever implementation it uses, so the same package works everywhere. PSR-15 recommends composing either such a factory or a response prototype.
- Why did PSR-15 reject the double-pass fn($request, $response, $next) signature?Its meta document names two problems. A response passed in has no guarantee of a usable state, since earlier code may already have written to its body. And `$next` is a `callable`, which cannot be strictly typed. PSR-17 factories replace the prototype role the passed response used to play.
- What is wrong with a PSR-15 middleware that calls $handler->handle($request) twice?Each call runs everything after the middleware again: controllers, queries, side effects such as writes or emails. A middleware should delegate at most once and work with the single response it gets back, or skip delegation entirely to short-circuit.
saying these in an interview costs you the question
- Says PSR-15 middleware receives a response object to modify alongside the request.
- Believes a middleware must always call the handler before returning.
- Thinks PSR-15 defines how middleware are ordered into a pipeline.
- Creates the 429 response with new on a concrete vendor class inside a reusable package.
- Claims PSR-15 interfaces accept any PSR-7 RequestInterface, not server requests.