skip to content

HTTP Message Interfaces

PSR-7 models requests, responses, streams and URIs as immutable values, and PSR-15, PSR-17 and PSR-18 build handlers, factories and clients on them. Interviewers probe the with* pitfall.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

6

In PSR-15, what do MiddlewareInterface::process() and RequestHandlerInterface::handle() declare, and how does a rate-limiting middleware reject or delegate a request?

level: middleimportance: must knowfreq 50%

answer

  1. Psr\Http\Server namespace
  2. handle(ServerRequestInterface): ResponseInterface
  3. process(request, handler): ResponseInterface
  4. single pass: no response argument
  5. PSR-17 factory for the 429 response

basics

~10 s

PSR-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 s

PSR-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
<?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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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.
open as a page

In PSR-7, why does calling $response->withHeader('X-Trace-Id', $id) without assigning the result leave the response unchanged?

level: middleimportance: must knowfreq 62%

basics

~10 s

PSR-7 messages are immutable: withHeader() returns an instance carrying the change and must leave the original untouched. Discarding the return value discards the change; write $response = $response->withHeader(...) or return the new instance.

open as a page

In PHP, what is PSR-7, and which interfaces does it define to represent HTTP requests, responses and their parts?

level: juniorimportance: should knowfreq 42%

basics

~10 s

PSR-7 is the PHP-FIG standard for HTTP messages: interfaces in Psr\Http\Message for requests, server requests, responses, bodies (StreamInterface), URIs and uploaded files. Code typed against them works with any conforming implementation.

open as a page

In PSR-7, what are ServerRequestInterface attributes for, and how does a middleware hand a derived value to the next handler?

level: middleimportance: should knowfreq 40%

basics

~10 s

ServerRequestInterface attributes carry values derived from the request, such as route matches or an authenticated client id. A middleware calls $request->withAttribute('name', $value) and passes that new request on; later code reads getAttribute('name', $default).

open as a page

In PSR-7, why can a second call to $request->getBody()->getContents() return an empty string?

level: middleimportance: should knowfreq 32%

basics

~20 s

StreamInterface wraps a PHP stream with a cursor. getContents() returns only the remaining bytes, so after one full read the cursor is at the end and the next call returns ''. Call rewind() first or cast with (string).

open as a page

When writing a framework-agnostic PHP package that calls a remote API, how do PSR-17 factories and PSR-18's ClientInterface keep it independent of any HTTP implementation, and what does sendRequest() throw?

level: seniorimportance: should knowfreq 28%

basics

~20 s

The package type-hints PSR-17 factories to build requests and PSR-18's ClientInterface to send them; the application injects concrete implementations. sendRequest() returns 4xx and 5xx responses normally and throws ClientExceptionInterface only when it cannot send or parse.

open as a page