If you have spent years working with Zend Framework or Laminas MVC, you have been insulated from HTTP message interfaces. The framework handles the request object, builds the response, and manages the lifecycle. When you encounter PSR-7 and PSR-15 in Mezzio documentation or modern PHP libraries, the terminology can feel unfamiliar even though the underlying concepts are things you already work with daily.
This article explains PSR-7 and PSR-15 without jargon. Every concept maps to something you already understand from working with traditional MVC frameworks.
What Problem Do These Standards Solve?
In traditional PHP, there is no standard way to represent an HTTP request or response as an object. Zend Framework has Zend\Http\Request. Symfony has Symfony\HttpFoundation\Request. Laravel uses Symfony’s request under the hood. CakePHP has its own. They all represent the same thing (an HTTP request), but their APIs are completely different.
This means libraries that need to work with HTTP messages have to either pick a framework or write adapters for each one. Authentication middleware written for Symfony cannot be used in Laminas. A logging middleware built for Zend Framework is useless in Slim.
PSR-7 solves this by defining a standard set of interfaces for HTTP messages. If your library works with PSR-7 interfaces, it works with any framework that implements them.
PSR-15 extends this to middleware. It defines a standard interface for request handlers and middleware, so middleware components are interchangeable between frameworks.
PSR-7: HTTP Messages
The Interfaces
PSR-7 defines seven interfaces, but you only need to understand three to be productive:
ServerRequestInterface represents the incoming HTTP request. It wraps everything in $_GET, $_POST, $_SERVER, $_COOKIE, $_FILES, and the request body into a single object.
1 | use Psr\Http\Message\ServerRequestInterface; |
If you have used $this->getRequest() in a Zend Framework controller, this is the same concept with a standardised interface.
ResponseInterface represents the outgoing HTTP response: status code, headers, and body.
1 | use Psr\Http\Message\ResponseInterface; |
StreamInterface represents the message body as a stream. You write to it or read from it. This avoids loading the entire response body into memory, which matters for large file downloads or streaming responses.
Immutability
PSR-7 objects are immutable. Every with* method returns a new object instead of modifying the existing one:
1 | // This does NOT modify $request |
If you come from Zend Framework where the request object is mutable, this is the single biggest mental shift. You must capture the return value. Calling withAttribute() without capturing the result is a bug that produces no error message.
Why immutability? It prevents middleware from silently modifying the request or response in ways that are hard to trace. Every transformation is explicit because it produces a new object.
Mapping from Superglobals
| Superglobal | PSR-7 Equivalent |
|---|---|
$_GET |
$request->getQueryParams() |
$_POST |
$request->getParsedBody() |
$_SERVER |
$request->getServerParams() |
$_COOKIE |
$request->getCookieParams() |
$_FILES |
$request->getUploadedFiles() |
php://input |
$request->getBody() |
Implementations
PSR-7 defines interfaces, not classes. You need a library that implements them:
- Laminas Diactoros (from the Laminas project)
- Guzzle PSR-7 (from the Guzzle HTTP client)
- Nyholm PSR-7 (lightweight, minimal implementation)
- Slim PSR-7 (from the Slim framework)
They are interchangeable because they implement the same interfaces.
PSR-15: HTTP Middleware
The Concept
Middleware is a function that sits between the request and the response. It can:
- Inspect or modify the request before it reaches your handler
- Inspect or modify the response before it is sent to the client
- Short-circuit the pipeline (e.g., return a 401 response without calling the handler)
If you have used Zend Framework’s event system to attach listeners to MvcEvent::EVENT_DISPATCH, you have been writing middleware in a different form. PSR-15 standardises the pattern.
MiddlewareInterface
1 | use Psr\Http\Message\ResponseInterface; |
The signature is simple: receive a request and a handler, return a response. The $handler->handle($request) call passes control to the next middleware in the pipeline (or the final request handler if there is no more middleware).
RequestHandlerInterface
1 | use Psr\Http\Message\ResponseInterface; |
A request handler is the endpoint. It receives a request and produces a response. In Zend Framework terms, this is your controller action.
The Middleware Pipeline
Middleware forms a pipeline. Each middleware wraps the next:
1 | Request |
Each middleware can transform the request on the way in and the response on the way out. The order matters: authentication should run before your handler, and CORS headers should be added to the response after your handler produces it.
How This Connects to Mezzio
Mezzio (the Laminas microframework, successor to Zend Expressive) is built entirely on PSR-7 and PSR-15. Every route maps to a request handler. Every cross-cutting concern (authentication, logging, error handling) is implemented as middleware.
The From Laminas MVC to Mezzio: Incremental Migration Blueprint guide covers the practical migration path. Understanding PSR-7 and PSR-15 is the conceptual foundation for that migration.
In Mezzio, a route definition looks like this:
1 | $app->get('/api/users/{id}', [ |
This reads directly: a GET request to /api/users/{id} passes through authentication middleware, then reaches the user profile handler. Every step uses the PSR-7 and PSR-15 interfaces.
Mapping Zend Framework Concepts to PSR-7 and PSR-15
| Zend Framework | PSR-7 / PSR-15 |
|---|---|
Zend\Http\Request |
ServerRequestInterface |
Zend\Http\Response |
ResponseInterface |
| Controller action | RequestHandlerInterface |
| MvcEvent listener | MiddlewareInterface |
$this->params()->fromQuery() |
$request->getQueryParams() |
$this->params()->fromPost() |
$request->getParsedBody() |
$this->params()->fromRoute() |
$request->getAttribute('id') |
$this->getResponse()->setStatusCode(404) |
$response->withStatus(404) |
| Plugin manager / controller plugins | Injected dependencies |
The patterns are the same. The interface names changed. The main philosophical difference is that PSR-15 middleware is explicit about the pipeline, while Zend Framework’s event system is implicit (listeners attach to events, execution order depends on priority numbers).
Why This Matters for Legacy Code
PSR-7 and PSR-15 are not academic standards. They have practical consequences for legacy PHP developers:
Library compatibility. Modern PHP libraries increasingly expect PSR-7 interfaces. Authentication packages, rate limiters, CORS handlers, and logging middleware are written against PSR-15. If your framework supports these interfaces, you can use those libraries directly.
Incremental migration. You can introduce PSR-15 middleware into a Laminas MVC application today. Laminas MVC supports piping PSR-15 middleware into its dispatch pipeline. This lets you write new cross-cutting concerns as middleware while keeping your existing controllers.
Framework independence. Code written against PSR-7 and PSR-15 interfaces is not locked to a specific framework. If you write a middleware for Mezzio, it also works in Slim, in a custom pipeline, or in any framework that supports these interfaces.
Testability. PSR-7 request and response objects are simple to construct in tests. You do not need a running web server or complex framework bootstrapping to test a request handler:
1 | $request = new ServerRequest('GET', '/api/users/42'); |
This is significantly simpler than testing a Zend Framework controller action, which requires bootstrapping the MVC application, dispatching through the event system, and extracting the response.
Common Mistakes
Forgetting immutability. Every with* call returns a new object. If you modify the request and pass the old request to the next handler, your modifications are lost. This is the most common PSR-7 bug.
Middleware order. Middleware runs in the order it is piped. Authentication must come before authorisation. Error handling should be early in the pipeline to catch exceptions from later middleware.
Stream consumption. The request body is a stream. If you read it once ($request->getBody()->getContents()), it is consumed. A second read returns an empty string. Rewind the stream if you need to read it again, or use getParsedBody() for form data and JSON.
Next Steps
Understanding PSR-7 and PSR-15 unlocks the modern PHP middleware ecosystem. If you are maintaining a Laminas MVC application, the immediate next step is the Modernising Zend Framework Applications guide, which covers how to introduce modern patterns into an existing application. For a full migration path from Laminas MVC to Mezzio’s middleware architecture, see the Incremental Migration Blueprint.
The Request Lifecycle Explained article covers how HTTP requests flow through PHP in general, providing the broader context for where PSR-7 and PSR-15 fit in the stack.