skip to content

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%

answer

  1. value objects, not setters
  2. with* returns an instance with the change
  3. original instance must stay untouched
  4. reassign: $response = $response->with...
  5. PHP 8.5 clone() with $withProperties

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.

solid answer

~40 s

PSR-7 models requests, responses and URIs as immutable value objects. Every `with*()` and `without*()` method MUST retain the state of the current instance and return an instance with the change, so `$response->withHeader('X-Trace-Id', $id);` as a bare statement builds a new response and throws it away. The fix is to capture it: `$response = $response->withHeader(...)`, or return it straight from the function. The specification allows returning `$this` when the call would not change anything, so never rely on object identity either. The design means a base request or a default response can be shared safely: nothing downstream can alter it behind your back. The one mutable part is the body, because `StreamInterface` wraps a PHP stream resource.

code

php · 15 lines
php
<?php
declare(strict_types=1);

use Psr\Http\Message\ResponseInterface;

function withRateLimitHeaders(ResponseInterface $response, int $limit, int $remaining): ResponseInterface
{
    // Wrong: each call builds a new response and discards it.
    $response->withHeader('X-RateLimit-Limit', (string) $limit);

    // Right: keep the instance each call returns.
    return $response
        ->withHeader('X-RateLimit-Limit', (string) $limit)
        ->withHeader('X-RateLimit-Remaining', (string) $remaining);
}

go deeper

for a junior

Remember one rule: every with* call returns a message carrying the change, and the old variable still points at the unchanged original. Assign it, chain it or return it.

for a middle

Explain the value-object contract behind with*, why sharing a base request is then safe, and why the body stream is the one mutable part of a message.

for a senior

Spot discarded with* results in review, add static analysis that reports unused return values of side-effect-free methods, and replace bodies with withBody() rather than writing into shared streams.

for a principal

Judge the trade-off PSR-7 made: allocation on every change and a steeper learning curve, in exchange for messages that no downstream component can alter behind another's back.

## The pitfall The most common bug with **PSR-7** is treating a `with...()` method as a setter: ```php $response->withHeader('X-Trace-Id', $traceId); // new response built, then discarded return $response; // original, without the header ``` No error, no warning: the code runs and the header is simply missing. Static analysers can flag it, because the return value of a method that has no side effects is being ignored, but PHP itself will not. ## What the specification requires PSR-7 describes messages and URIs as **value objects**: their identity is the sum of their parts, and a change to any part is a different value. The interfaces spell this out on every mutator: - the method **MUST** be implemented so that it retains the immutability of the message; - it **MUST** return an instance that has the changed state; - the instance it was called on stays as it was. The mutators follow a naming pattern: `withProtocolVersion()`, `withHeader()`, `withAddedHeader()`, `withoutHeader()`, `withBody()` on every message; `withMethod()`, `withUri()`, `withRequestTarget()` on requests; `withStatus()` on responses; `withAttribute()`, `withoutAttribute()`, `withQueryParams()`, `withParsedBody()`, `withCookieParams()`, `withUploadedFiles()` on server requests; and `withScheme()`, `withHost()`, `withPath()`, `withQuery()` and friends on `UriInterface`. ## Correct usage 1. **Reassign** when you keep working with the message: `$response = $response->withHeader(...)`. 2. **Chain** when you apply several changes: `$response->withStatus(201)->withHeader('Location', $url)`. 3. **Return** the new instance from a function or middleware; the caller only sees what you return. Because the method may return `$this` when nothing changes (the meta document allows it, since equal values are interchangeable), do not compare instances with `===` to detect a change. ## Why the standard chose immutability | Concern | Mutable message objects | PSR-7 value objects | |---|---|---| | Shared base request in a client | every send must reset headers and body | build once, derive per call with `with*()` | | Middleware passing a request along | a later component can change what an earlier one already inspected | each component sees a stable value | | URI composed in a request | changing the URI object changes the request | changing a URI yields a new URI | | Debugging | state changes happen anywhere | every change is an explicit assignment | The cost is allocation: each `with*()` usually clones the object. Implementations keep that cheap because a clone is shallow and PHP arrays inside it are copied only when written. ## The exception: the body stream `StreamInterface` is **not** immutable. It wraps a PHP stream resource, and anything holding that resource can move its cursor or write to it. Calling `$response->getBody()->write('...')` therefore changes the body of that response and of any other message sharing the stream. When you need to replace a body, create a new stream and attach it with `withBody()`. ## Implementing a with* method In an implementation, the usual pattern is to clone, change the clone and return it: ```php public function withStatus(int $code, string $reasonPhrase = ''): static { $new = clone $this; $new->statusCode = $code; $new->reasonPhrase = $reasonPhrase; return $new; } ``` PHP 8.5 turned `clone` into a function that accepts a second argument, `$withProperties`, which assigns properties on the copy and may reinitialise **readonly** properties during the clone. That lets an implementation declare its state `readonly` and still write each mutator as one expression: `return clone($this, ['statusCode' => $code]);`, called from inside the class so the readonly properties are in scope. ## Checklist - Treat every `with*()` result as the only copy that has the change. - Assign, chain or return it; never call it as a bare statement. - Replace bodies with `withBody()` and a fresh stream instead of writing into a shared one. ## Where the bug hides in real code The bare call is easy to spot in a three-line function and easy to miss elsewhere: - **inside a branch**, where only one path forgets to reassign, so the header appears on some responses and not others; - **inside a loop** that adds several headers, where each iteration starts again from the unchanged original; - **in a helper** that receives a message, calls `with*()` and returns `void`, so the caller never gets the new instance; - **on a nested value**, such as `$request->getUri()->withPath('/v2')`, which builds a new URI but leaves the request pointing at the old one until you call `withUri()`. Tests catch it only if they assert on the returned object, which is another reason to make helpers return the message.

  • May a PSR-7 with* method return $this?
    Yes, when the argument would not change the value. The PSR-7 meta document says equal values are interchangeable, so returning `$this` is functionally equivalent and saves a clone. That is why code must never use `===` to decide whether a message changed.
  • If PSR-7 messages are immutable, why can $response->getBody()->write('x') change a response?
    `StreamInterface` is deliberately mutable: it wraps a PHP stream resource, and anything holding the resource can write to it or move its cursor. Writing through `getBody()` changes that response's body in place. To replace a body safely, create a new stream and attach it with `withBody()`.
  • How does PHP 8.5 simplify writing with* methods on a class with readonly properties?
    PHP 8.5 made `clone` a function with a `$withProperties` array that assigns properties on the copy, including reinitialising readonly ones. From inside the class, `return clone($this, ['statusCode' => $code]);` replaces the clone-then-assign sequence, which readonly properties previously made awkward.

A PSR-7 message is like a printed form: to change a field you fill in a fresh copy with the new value, and anyone still holding the old copy sees exactly what was printed before. Forget to keep the new copy and the change is gone.

saying these in an interview costs you the question

  • Treats withHeader() as a setter that changes the response in place.
  • Believes a PSR-7 implementation throws when a with* result is ignored.
  • Compares messages with === to detect whether a with* call changed anything.
  • Claims the body stream is immutable like the rest of the message.
  • Thinks with* must always allocate a new object, never returning $this.