PSR-7: HTTP Messages, Immutability, and Streams
Understand the PSR-7 message interfaces (RequestInterface, ResponseInterface, ServerRequestInterface, UriInterface, StreamInterface), use the immutable with*() method pattern instead of mutating properties, work with headers and the request/response body as a StreamInterface, and see how PSR-7 differs from PHP's native superglobals ($_GET, $_POST, $_SERVER, echo).
PHP historically has no built-in object representation of an HTTP request or response -- you read incoming data from superglobals like $_GET, $_POST, $_SERVER, and $_COOKIE, and you send a response by calling header() and echo, both of which act on PHP's runtime state as side effects rather than as data you can pass around, inspect, or test in isolation. PSR-7 (PHP Standard Recommendation 7, 'HTTP Message Interfaces') fixes this by defining a standard set of interfaces -- RequestInterface, ResponseInterface, ServerRequestInterface, UriInterface, and StreamInterface -- so that any framework or library can represent an HTTP request or response as an ordinary, fully immutable PHP object. Because the interfaces are standardized (not tied to any single framework), a middleware, an HTTP client, or a router written against PSR-7 works with a request object built by Laravel, Slim, Mezzio, or a plain script using nyholm/psr7 -- the object shape is the contract, not the implementation.
The core interfaces
PSR-7 defines a small family of interfaces, all living under the Psr\Http\Message namespace. MessageInterface is the base shared by both requests and responses: it exposes the protocol version, headers (getHeaders(), getHeader(), hasHeader()), and the body as a StreamInterface. RequestInterface extends MessageInterface and adds the outgoing side of a request -- the HTTP method and a UriInterface target -- and is what an HTTP client sends. ServerRequestInterface extends RequestInterface further and adds everything specific to a request an application receives on the server: parsed query parameters, parsed request body, uploaded files, cookies, and an arbitrary 'attributes' bag that middleware use to pass computed data (like an authenticated user) down a pipeline.
ResponseInterface, also extending MessageInterface, represents what gets sent back: a status code, a reason phrase, headers, and a body stream. UriInterface models a URI's individual components (scheme, host, port, path, query, fragment) as a structured, immutable value object instead of a raw string, so code can read or replace one component (say, the query string) without manually parsing and reassembling a URL by hand. StreamInterface wraps the request or response body -- which may be a large file, a network socket, or an in-memory string -- behind a uniform read/write/seek API, so the same code path handles a tiny JSON body and a multi-gigabyte file upload without treating them differently.
• MessageInterface -- shared base: protocol version, headers, body (StreamInterface) • RequestInterface -- outgoing request: HTTP method + UriInterface target • ServerRequestInterface -- incoming server request: query params, parsed body, uploaded files, cookies, attributes • ResponseInterface -- status code, reason phrase, headers, body • UriInterface -- structured URI: scheme, host, port, path, query, fragment • StreamInterface -- uniform read/write/seek wrapper around a message body
Immutability: the with*() method pattern
Every PSR-7 object is immutable. There is no setHeader(), setBody(), or setStatusCode() method anywhere in the specification -- instead, every mutating-looking operation is named with a 'with' prefix (withHeader(), withBody(), withStatus(), withUri(), and so on) and, critically, none of them actually mutate the object they're called on. Each with*() method returns a brand-new instance that is a copy of the original with exactly one change applied, leaving the original object completely untouched. This is the same design pattern as PHP's own DateTimeImmutable versus the mutable DateTime: calling $request->withHeader('X-Foo', 'bar') does nothing to $request itself; you must capture the return value ($newRequest = $request->withHeader(...)) to see the change.
This immutability is not a stylistic preference -- it is what makes PSR-7 objects safe to pass through a chain of middleware. If a request object could be mutated in place, one middleware changing a header would silently affect every other piece of code holding a reference to that same object, including code earlier in the pipeline that has already read it, or the framework's own routing logic. Because every with*() call produces a distinct new object, each layer of a middleware stack can transform the request or response by producing its own new version and passing that specific instance forward explicitly, with no possibility of an invisible, action-at-a-distance mutation reaching code that never received the new instance.
The tradeoff is that you must remember to reassign: $request->withHeader('X-Foo', 'bar'); on its own line, with the return value discarded, is a silent no-op and a very common bug for developers coming from mutable, setter-based APIs. Chaining calls together ($request->withHeader(...)->withBody(...)->withStatus(...)) works naturally, since each with*() call in the chain operates on the new instance the previous call just returned.
Headers, the body stream, and a real PSR-7 implementation
Headers in PSR-7 are case-insensitive for lookup (getHeader('content-type') and getHeader('Content-Type') return the same values) but preserve their originally-set case when retrieved with getHeaders(), and a single header name can carry multiple values (getHeader() always returns an array of strings, even for a single-value header; getHeaderLine() is a convenience that joins them with a comma into one string). withHeader() replaces any existing values for that name outright, while withAddedHeader() appends an additional value alongside whatever is already there -- a distinction that matters for headers like Set-Cookie or Link that legitimately repeat.
The message body is never a plain string in PSR-7 -- it is always a StreamInterface instance, accessed via getBody() and replaced (not mutated) via withBody(). StreamInterface exposes methods like read(), write(), getContents(), rewind(), and __toString(), so the same interface transparently handles an in-memory string body, a body streamed from a temp file, or a body streaming directly off a socket, without the calling code needing to know which. PSR-7 itself only defines interfaces -- it ships no implementation -- so real code depends on a concrete package such as nyholm/psr7 or guzzlehttp/psr7, both of which provide lightweight, spec-compliant Request, Response, ServerRequest, Uri, and Stream classes, plus factory helpers for building them from PHP's own superglobals or raw strings.
PSR-17 (HTTP Factories) complements PSR-7 by standardizing how those objects get constructed -- interfaces like RequestFactoryInterface and StreamFactoryInterface -- so code that needs to build a new request or response doesn't have to depend on a specific implementation's constructor signature, only on the factory interfaces, keeping the code portable across whichever PSR-7 implementation is installed.
PSR-7 versus PHP's native superglobals
Native PHP reads incoming request data through several separate, always-available global arrays -- $_GET for the query string, $_POST for a parsed form body, $_SERVER for method/headers/URI pieces, $_COOKIE, $_FILES -- and writes a response with the global functions header() and echo (or print). These are simple and require no setup, but they are global mutable state: any function anywhere in the call stack can read or, worse, modify them, there is no single object to pass to a function that needs 'the current request', and testing code that touches superglobals means manipulating PHP's actual global state before and after every test, which is fragile and not thread-safe in the long-running-process (e.g. Swoole, RoadRunner) deployments increasingly common in modern PHP.
A ServerRequestInterface, by contrast, packages all of that same information -- query params, parsed body, headers, cookies, uploaded files -- into a single, ordinary, immutable object that can be constructed once (typically from the superglobals, via a PSR-17 factory, right at the front controller) and then passed explicitly as a function argument to every layer of the application that needs it. This makes request data explicit in a function's signature instead of implicit ambient state, makes unit testing trivial (just construct a ServerRequest with whatever test data you want, no global state to fake), and makes it safe to run the same request-handling code in a long-lived worker process that serves many requests without $_GET or $_SERVER leaking stale data between them.
• $_GET / $_POST / $_SERVER / $_COOKIE / $_FILES -- global, mutable, always ambient • header() / echo -- side effects on PHP's runtime output buffer, not inspectable objects • ServerRequestInterface -- one immutable object, explicitly passed, safe to construct repeatedly for tests or per-request in long-lived workers • ResponseInterface -- built up with with*() calls and returned as data, then emitted by a separate 'emitter' at the very edge of the application
Core PSR-7 interface methods
PSR-7 defines only interfaces; you need a real implementation such as nyholm/psr7 or guzzlehttp/psr7 installed via Composer to actually construct these objects. Every with*() method returns static -- a new instance of the same concrete class -- never void, and never mutates the receiver.
// Psr\Http\Message\MessageInterface (shared by requests and responses)
getProtocolVersion(): string
getHeaders(): array
hasHeader(string $name): bool
getHeader(string $name): array
getHeaderLine(string $name): string
withHeader(string $name, $value): static // replaces, returns NEW instance
withAddedHeader(string $name, $value): static // appends, returns NEW instance
withoutHeader(string $name): static // removes, returns NEW instance
getBody(): StreamInterface
withBody(StreamInterface $body): static
// Psr\Http\Message\ServerRequestInterface (extends RequestInterface)
getQueryParams(): array
getParsedBody(): array|object|null
getUploadedFiles(): array
getCookieParams(): array
getAttribute(string $name, $default = null): mixed
withAttribute(string $name, $value): static // for passing data down a middleware chain
// Psr\Http\Message\ResponseInterface
getStatusCode(): int
withStatus(int $code, string $reasonPhrase = ''): static
// Psr\Http\Message\UriInterface
getScheme(): string
getHost(): string
getPath(): string
getQuery(): string
withPath(string $path): static
withQuery(string $query): staticCode Examples
Each withHeader()/withBody() call is reassigned back to $response, because the calls do not mutate in place -- they return a new object each time. Casting the body stream to a string (or calling getContents()) reads its full contents, the same way __toString() on any StreamInterface implementation works. Expected result: 200 text/plain Hello, PSR-7!
$response = $response->withHeader('Content-Type', 'text/plain');
The ServerRequestCreator (a PSR-17-backed helper from nyholm/psr7-server) reads $_GET, $_SERVER, $_COOKIE, and the raw input stream exactly once and packages them into a single immutable ServerRequestInterface, which is then passed as an ordinary function argument -- routeHandler() never touches a superglobal directly, which makes it trivially testable by constructing a fake ServerRequest instead.
$nextPageUri = $uri->withQuery('page=' . ...);
JsonResponder never mutates its inputs and never produces output as a side effect -- respond() is a pure function from a request plus data to a brand-new ResponseInterface, which is exactly why it can be unit-tested with a plain ServerRequest built in a test (no real HTTP server, no output buffering) and why the resulting object can be freely reused, inspected, or passed to another layer (like an emitter) before anything is actually sent to a client. Expected result: 200 application/json; charset=utf-8 {"id":42,"name":"Ada Lovelace"} return (new Response())->withStatus($status)->withHeader(...)->withBody($body);
<?php
require 'vendor/autoload.php';
use Nyholm\Psr7\Response;
// Build a response purely as data, with no output yet -- nothing has
// been sent to the browser at this point, it is just an object.
$response = new Response(200);
$response = $response->withHeader('Content-Type', 'text/plain');
$response = $response->withBody(
\Nyholm\Psr7\Stream::create('Hello, PSR-7!')
);
echo $response->getStatusCode() . "\n"; // 200
echo $response->getHeaderLine('Content-Type') . "\n"; // text/plain
echo (string) $response->getBody() . "\n"; // Hello, PSR-7!<?php
require 'vendor/autoload.php';
use Nyholm\Psr7Server\ServerRequestCreator;
use Nyholm\Psr7\Factory\Psr17Factory;
// Build a ServerRequestInterface FROM PHP's own superglobals, once,
// right at the front controller -- everything downstream then works
// with the immutable object, never touching $_GET/$_POST/$_SERVER again.
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator(
$psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory
);
$request = $creator->fromGlobals();
function routeHandler(\Psr\Http\Message\ServerRequestInterface $request): void
{
$uri = $request->getUri();
$query = $request->getQueryParams();
echo 'Method: ' . $request->getMethod() . "\n";
echo 'Path: ' . $uri->getPath() . "\n";
echo 'Query param "page": ' . ($query['page'] ?? '1') . "\n";
// Deriving a NEW uri with one changed component -- withQuery()
// returns a fresh UriInterface, the original $uri is untouched.
$nextPageUri = $uri->withQuery('page=' . ((int) ($query['page'] ?? 1) + 1));
echo 'Next page URI: ' . (string) $nextPageUri . "\n";
}
routeHandler($request);<?php
require 'vendor/autoload.php';
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\Stream;
final class JsonResponder
{
public function respond(
ServerRequestInterface $request,
array $data,
int $status = 200
): ResponseInterface {
$body = Stream::create(
json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
);
// Build the whole response through a chain of with*() calls --
// each one hands back a new instance, so the FINAL expression
// is the fully-assembled, still-immutable response object.
return (new Response())
->withStatus($status)
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withHeader('X-Request-Id', $request->getHeaderLine('X-Request-Id') ?: 'unknown')
->withBody($body);
}
}
$fakeRequest = new \Nyholm\Psr7\ServerRequest('GET', '/api/users/42');
$responder = new JsonResponder();
$response = $responder->respond($fakeRequest, ['id' => 42, 'name' => 'Ada Lovelace']);
echo $response->getStatusCode() . "\n";
echo $respon
…with*() immutability: each call returns a new object
Forgetting to capture a with*() call's return value ($response->withHeader(...); alone, without reassignment) silently does nothing -- the original object is never touched.
$r0 = new Response(200)
|
| ->withHeader('Content-Type','application/json')
v
$r1 (NEW object, $r0 unchanged)
|
| ->withStatus(201)
v
$r2 (NEW object, $r0 and $r1 unchanged)
|
| ->withBody($stream)
v
$r3 (NEW object -- this is the one actually returned/sent)
$r0, $r1, $r2 still exist exactly as they were originally built --
nothing about the chain ever mutates a prior instance in place.Important Rules
Never expect a with*() method to mutate its receiver -- always capture and use its return value, whether by reassignment or by chaining further with*() calls off it.
Treat the request/response body as a StreamInterface, not a string -- use getBody()->getContents() or cast to string to read it, and withBody() (never direct property assignment) to replace it.
Build a ServerRequestInterface from PHP's superglobals exactly once, at the application's entry point, and pass it explicitly through the rest of the code instead of reaching for $_GET/$_POST/$_SERVER deeper in the call stack.
Remember PSR-7 defines interfaces only -- you need a concrete implementation (nyholm/psr7, guzzlehttp/psr7, laminas-diactoros) installed to actually construct Request/Response/Stream objects.
Prefer PSR-17 factories over a specific implementation's constructor when your code needs to build new PSR-7 objects, so it stays portable across whichever implementation is installed.
Where this shows up
PSR-7 is the foundation nearly every modern PHP framework and micro-framework interoperability layer builds on -- Slim, Mezzio, and many Symfony/Laravel bridge packages (via PSR-7 adapters) all speak PSR-7 at their HTTP boundary, which is precisely what lets a single middleware package (say, a CORS handler or a rate limiter) work unmodified across frameworks that otherwise have completely different internal request/response objects. Guzzle, PHP's most widely used HTTP client library, also builds its request/response objects on PSR-7 (via guzzlehttp/psr7), so the exact same ResponseInterface API you use to build a server response is also what you get back from an outgoing HTTP call.
<?php // Looks like it adds a header, but does nothing at all: $response->withHeader('X-Powered-By', 'MyApp'); return $response; // header was never applied -- $response is untouched
<?php // withHeader() returns a NEW instance -- capture it: $response = $response->withHeader('X-Powered-By', 'MyApp'); return $response;
PSR-7 objects are immutable, so withHeader() (like every with*() method) has no effect on the object it was called on -- it only produces a new object as its return value. Calling it as a bare statement without reassigning discards that new object entirely, leaving $response exactly as it was, which is a very easy mistake for anyone used to mutable setter methods like setHeader().
[multiple-choice] What happens when you call $request->withHeader('X-Foo', 'bar') and do NOT assign its return value to a variable? A. The header is added to $request in place, exactly like a setter method would B. It throws a TypeError because with*() methods require their return value to be used C. Nothing observable happens to $request -- withHeader() returns a new instance and never mutates the original object D. It silently modifies the global request state used by the framework's router
C. Nothing observable happens to $request -- withHeader() returns a new instance and never mutates the original object — PSR-7 objects are immutable. Every with*() method returns a new instance representing the requested change and leaves the object it was called on completely unmodified, so discarding the return value is equivalent to not having made the call at all.
[multiple-choice] Why does PSR-7 represent a message body as a StreamInterface instead of a plain PHP string? A. Strings cannot hold binary data in PHP B. It provides a uniform read/write/seek API that works the same whether the body is a small in-memory string, a large file, or data streamed from a socket, without loading everything into memory at once C. StreamInterface is required by PHP's type system for all function return values D. It makes the body immutable, which a string type cannot be
B. It provides a uniform read/write/seek API that works the same whether the body is a small in-memory string, a large file, or data streamed from a socket, without loading everything into memory at once — A StreamInterface abstracts over where the body's bytes actually live and how they're accessed, so the same code can transparently handle a tiny JSON payload and a multi-gigabyte upload through methods like read(), write(), and getContents(), which a plain string value cannot do.
[multiple-choice] What is the main practical advantage of a ServerRequestInterface object over reading $_GET, $_POST, and $_SERVER directly? A. It is faster at the CPU level because superglobals are slow B. It packages all incoming request data into one explicit, immutable object that can be passed as a function argument and constructed freely in tests, instead of relying on ambient global state C. It automatically validates and sanitizes all incoming data D. It removes the need for a web server entirely
B. It packages all incoming request data into one explicit, immutable object that can be passed as a function argument and constructed freely in tests, instead of relying on ambient global state — The core benefit is explicitness and testability: a ServerRequestInterface turns implicit, globally-mutable ambient state into one ordinary object that a function declares it needs in its signature, which can be constructed with arbitrary test data with no global state to fake or reset, and is safe to use in long-lived worker processes where superglobals could otherwise leak between requests.
Using a PSR-7 implementation (assume Nyholm\Psr7\Response and Nyholm\Psr7\Stream are available), write a function notFoundResponse(): Psr\Http\Message\ResponseInterface that builds and returns a 404 response with header Content-Type: text/plain and body text 'Not Found', built entirely through with*() chaining starting from new Response(). Then write a second function addSecurityHeaders(Psr\Http\Message\ResponseInterface $response): Psr\Http\Message\ResponseInterface that returns a new response with an added X-Content-Type-Options: nosniff header, without mutating the response it was given.
function notFoundResponse(): \Psr\Http\Message\ResponseInterface { return (new \Nyholm\Psr7\Response()) ->withStatus(404) ->withHeader('Content-Type', 'text/plain') ->withBody(\Nyholm\Psr7\Stream::create('Not Found')); } function addSecurityHeaders(\Psr\Http\Message\ResponseInterface $response): \Psr\Http\Message\ResponseInterface { return $response->withHeader('X-Content-Type-Options', 'nosniff'); }
Both functions must return the result of with*() calls rather than mutate their input -- addSecurityHeaders() should return $response->withHeader('X-Content-Type-Options', 'nosniff'), never modify $response and then return the same variable expecting it changed in place.