PSR-7 was voted through and the document landed in the accepted directory of the FIG standards repository on 19 May. I read it three weeks later on a Tuesday evening, then again on Wednesday morning, because the first pass left me convinced that half of the interface was broken.
The reason I was reading it is boring. The ERP pushes invoice batches to a client’s accounting API through a cURL wrapper I wrote in 2014 that nobody enjoys touching. Guzzle 6.0.0 came out on 26 May, and its upgrade guide opens by saying the library now uses PSR-7 for HTTP messages, and that because those messages are immutable the whole event system was replaced with middleware. Taking the client means taking the interfaces.
withHeader() does not set a header
This was my first hour, and it produced no error, no warning, and no header:
<?php
// Wrong. Both calls build a new message and throw it away.
$request = new Request('POST', $endpoint);
$request->withHeader('Content-Type', 'application/json');
$request->withHeader('X-Batch-Id', $batchId);
// Right. Every with* method returns a new instance.
$request = (new Request('POST', $endpoint))
->withHeader('Content-Type', 'application/json')
->withHeader('X-Batch-Id', $batchId);
$request->getHeader('X-Batch-Id'); // array('2015-06-19-004')
$request->getHeaderLine('X-Batch-Id'); // '2015-06-19-004'
The specification is not subtle about it. Every mutating method on PsrHttpMessageMessageInterface is named withHeader(), withAddedHeader(), withoutHeader(), withProtocolVersion(), withBody(), each documented as returning static, each carrying the same sentence about retaining the immutability of the message. RequestInterface adds withMethod(), withUri() and withRequestTarget() in the same shape. The meta document gives the reasoning: messages are value objects, their identity is the aggregate of all their parts, so changing one part produces a different message. Once you accept that premise the naming is the only honest option, and eighteen months of $request->headers->set() muscle memory is the thing that is wrong.
Reading headers is split in a way I did not expect either. getHeader() returns an array of values, getHeaderLine() returns them joined with commas, and the spec warns that not every header survives comma concatenation, Set-Cookie being the example. Lookups are case insensitive, but the original casing has to be preserved in getHeaders(), because servers on the other end do care. Section 1.2 then spends a five row table on what withUri($uri, true) does to the Host header. I read it twice and wrote a test for the one case I hit.
Why getBody() returns a stream
The interface returns StreamInterface from getBody(), not a string, and section 1.3 explains why in one line: a message body can be extremely large, and representing it as a string means holding all of it in memory. Our invoice batch export is 31 MB of XML on a good month, our memory_limit is 256M, and the cURL wrapper it replaces builds that payload as a single string. So the argument landed.
StreamInterface exposes capability probes, isReadable(), isWritable() and isSeekable(), alongside seek(), rewind(), read(), write(), getContents(), getSize(), detach() and getMetadata(). It also defines __toString(), the escape hatch that makes the abstraction bearable in logs. Where a string body is genuinely fine, the spec points at php://memory and php://temp.
The honest part of the document is the paragraph admitting that StreamInterface does not model immutability at all. It cannot: wrap a real PHP stream and anything holding the resource can move the cursor. The recommendation is read-only streams for server side requests and client side responses. Zend’s Diactoros, the reference implementation, switched its default request body from php://memory to php://temp in 1.0.1 on 26 May to avoid out of memory conditions. I read a response body twice in my first test and got an empty string the second time, which is exactly what the spec warned about.
Attributes, and the superglobals under them
ServerRequestInterface adds what PHP normally hands you through superglobals: getServerParams(), getCookieParams(), getQueryParams(), getParsedBody() and getUploadedFiles(). Server params have no setter by design, since they describe the request as it arrived. The rest do, because a body can be parsed differently once you know the content type.
Then there is the attributes property: getAttributes(), getAttribute($name, $default = null), withAttribute() and withoutAttribute(). It is the spec’s answer to everything a framework wants to hang off a request after inspecting it, from matched route parameters to the user resolved out of an authorization header, and it is how two pieces of code that know nothing about each other pass a value along. Diactoros 1.0.1 carries a fix for attributes not being initialised to an empty array, which tells you how new all of this is.
getUploadedFiles() is the part I would take tomorrow if I could. It returns a tree of UploadedFileInterface instances shaped like the form input names, which kills the transposed $_FILES structure where files[0] and files[1] arrive as parallel arrays under name, type and tmp_name. We have a loop that untangles that for document uploads and it has a bug in it I have never bothered to find.
Wrapping a mutable request is where it stops being fun
We are on Symfony 2.3, with the 2.7 LTS move sitting on my desk for next month, and HttpFoundation’s Request is the opposite design on purpose: public $attributes and $headers bags you write into, a setMethod(), a duplicate(), and getContent($asResource = false) handing back either a string or a resource.
Converting one way is easy. Sixty lines, mostly moving $request->headers->all() across and wrapping getContent(true) in a stream. Converting back is where the model fights you, because withAttribute() returns a new object and the kernel that called your code still holds the old one. Middleware shaped code assumes the caller takes the returned instance. Symfony’s kernel does not, because nothing in 2.x is built that way.
That is my whole adoption: one adapter class, one Guzzle 6 client, the interfaces nowhere near the controllers. Diactoros 1.0.3 on 4 June dropped its minimum PHP version to 5.4 specifically to give Symfony 2.7 users an easier path, so the bridging problem is on other desks too. I will look again when someone ships a bridge I did not write.
Sources
- PSR-7: HTTP message interfaces: the interface definitions, the header and stream sections, the server request and uploaded file rules quoted here.
- PSR-7 meta document: the value object and immutability rationale.
- The commit adding PSR-7 to the accepted directory: dates acceptance to 19 May 2015.
- Guzzle 5.0 to 6.0 upgrade guide: states that Guzzle 6 uses PSR-7 and why immutability forced the move to middleware.
- Guzzle changelog at 6.0.0: the 26 May 2015 release date.
- Diactoros changelog: 1.0.0 on 21 May, the
php://tempand attributes fixes in 1.0.1, and the PHP 5.4 minimum in 1.0.3. - Symfony 2.3 HttpFoundation Request: the mutable public bags,
setMethod(),duplicate()andgetContent($asResource).