Ahnii!
We’ve seen how PSR-7 defines HTTP messages and PSR-17 creates them. Now, how do we actually process a request and produce a response? That’s PSR-15.
Prerequisites: PHP OOP. Required: Read PSR-7 first – PSR-15 builds directly on it.
What Problem Does PSR-15 Solve? (3 minutes)
Think of your HTTP request as a traveler going through an airport. Each checkpoint (middleware) can inspect your boarding pass (headers), check your luggage (body), add a stamp to your passport (modify the request), or turn you away entirely (return an error response). The final gate (the handler) is your destination – it’s where the actual work happens and you get your response.
Without a standard, every framework’s middleware is incompatible. Middleware written for Slim can’t be used in Laravel. Middleware from Mezzio won’t work in Symfony. PSR-15 fixes this by defining two simple interfaces that every framework can agree on.
The result? You can write an authentication middleware once and use it in any PSR-15 compliant application. Just like PSR-7 gave us a standard “shape” for HTTP messages, PSR-15 gives us a standard “shape” for processing them.
Core Interfaces (5 minutes)
PSR-15 defines just two interfaces. That’s it – two interfaces that power every middleware pipeline.
RequestHandlerInterface
<?php
namespace Psr\Http\Server;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface RequestHandlerInterface
{
/**
* Handles a request and produces a response.
*/
public function handle(ServerRequestInterface $request): ResponseInterface;
}
This is the final destination. A handler takes a request and returns a response – no delegation, no chain. Think of it as the gate at the end of the airport.
MiddlewareInterface
<?php
namespace Psr\Http\Server;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
interface MiddlewareInterface
{
/**
* Process a request and delegate to the next handler.
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface;
}
Here’s the key insight: middleware receives both the request AND the next handler in the chain. This means middleware can:
- Modify the request before passing it along
- Call the next handler with
$handler->handle($request)to continue the chain - Modify the response that comes back
- Short-circuit by returning a response without calling the handler at all
That $handler->handle($request) call is the delegation pattern – it passes control to the next layer in the pipeline.
Real-World Implementation (10 minutes)
Let’s build a middleware pipeline for a blog API. We’ll create four middleware/handler classes, then wire them into a pipeline.
LoggingMiddleware
<?php
namespace App\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
class LoggingMiddleware implements MiddlewareInterface
{
public function __construct(
private LoggerInterface $logger
) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
// Log the incoming request
$this->logger->info('Request', [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
]);
// Delegate to the next handler
$response = $handler->handle($request);
// Log the outgoing response
$this->logger->info('Response', [
'status' => $response->getStatusCode(),
]);
return $response;
}
}
Notice how we log before and after delegating. The request flows in, passes through to the next layer, and the response bubbles back up.
AuthMiddleware
<?php
namespace App\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Nyholm\Psr7\Response;
class AuthMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$token = $request->getHeaderLine('Authorization');
if (empty($token) || !$this->validateToken($token)) {
// Short-circuit -- return 401 without calling the handler
return new Response(
401,
['Content-Type' => 'application/json'],
json_encode(['error' => 'Unauthorized'])
);
}
// Token is valid -- attach user data to the request
$request = $request->withAttribute('user', $this->getUserFromToken($token));
return $handler->handle($request);
}
private function validateToken(string $token): bool
{
return str_starts_with($token, 'Bearer ') && strlen($token) > 10;
}
private function getUserFromToken(string $token): array
{
return ['id' => 1, 'name' => 'Blog Author'];
}
}
If the token is missing or invalid, the pipeline stops right here – no further middleware or handler runs. If the token is valid, the middleware attaches user data to the request using withAttribute() so downstream code can access it.
CorsMiddleware
<?php
namespace App\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class CorsMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
// Delegate first, then modify the response
$response = $handler->handle($request);
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
}
CORS middleware modifies the response, so it delegates first and then adds the headers on the way back out.
BlogPostHandler
<?php
namespace App\Http\Handler;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Nyholm\Psr7\Response;
class BlogPostHandler implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
// Access user data set by AuthMiddleware
$user = $request->getAttribute('user');
$posts = [
['id' => 1, 'title' => 'Getting Started with PSR-15', 'author' => $user['name']],
['id' => 2, 'title' => 'Middleware Pipelines in PHP', 'author' => $user['name']],
];
return new Response(
200,
['Content-Type' => 'application/json'],
json_encode($posts)
);
}
}
This is the final destination – no delegation, just produce a response.
MiddlewarePipeline
<?php
namespace App\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class MiddlewarePipeline implements RequestHandlerInterface
{
/** @var MiddlewareInterface[] */
private array $middleware;
private int $index = 0;
public function __construct(
array $middleware,
private RequestHandlerInterface $handler
) {
$this->middleware = $middleware;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
// If we've run through all middleware, call the final handler
if ($this->index >= count($this->middleware)) {
return $this->handler->handle($request);
}
// Get the next middleware and advance the index
$next = $this->middleware[$this->index];
$this->index++;
// Process the middleware, passing $this as the next handler
return $next->process($request, $this);
}
}
Wiring It All Together
<?php
use App\Http\MiddlewarePipeline;
use App\Http\Middleware\{LoggingMiddleware, AuthMiddleware, CorsMiddleware};
use App\Http\Handler\BlogPostHandler;
$pipeline = new MiddlewarePipeline(
[new LoggingMiddleware($logger), new AuthMiddleware(), new CorsMiddleware()],
new BlogPostHandler()
);
$response = $pipeline->handle($serverRequest);
Here’s how a request flows through the pipeline:
Request → Logging → Auth → CORS → BlogPostHandler
↓
Response ← Logging ← Auth ← CORS ← Response
Each middleware wraps the next layer. Logging sees the request first and the response last. Auth can stop the chain entirely. CORS adds headers on the way out.
Common Mistakes and Fixes
1. Forgetting to Call $handler->handle()
If middleware doesn’t delegate, the chain breaks and no downstream middleware or handler ever runs.
// Bad -- returns a response without delegating
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
return new Response(200, [], 'Handled!');
}
// Good -- delegates, then modifies the response
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$response = $handler->handle($request);
return $response->withHeader('X-Custom', 'value');
}
2. Modifying Request State Directly
Don’t use globals or superglobals to pass data between middleware. Use the request’s attributes instead.
// Bad -- storing data in globals
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$_SESSION['user'] = $this->getUser($request);
return $handler->handle($request);
}
// Good -- using request attributes
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$user = $this->getUser($request);
$request = $request->withAttribute('user', $user);
return $handler->handle($request);
}
3. Doing Too Much in One Middleware
Each middleware should have one job. If your middleware is handling auth, logging, AND CORS, split it up.
// Bad -- one middleware doing three jobs
class KitchenSinkMiddleware implements MiddlewareInterface
{
public function process($request, $handler): ResponseInterface
{
$this->logger->info($request->getMethod()); // logging
if (!$this->checkAuth($request)) { return new Response(401); } // auth
$response = $handler->handle($request);
return $response->withHeader('Access-Control-Allow-Origin', '*'); // CORS
}
}
// Good -- three focused middleware classes
$pipeline = new MiddlewarePipeline(
[new LoggingMiddleware($logger), new AuthMiddleware(), new CorsMiddleware()],
new BlogPostHandler()
);
Framework Integration
Laravel
Laravel middleware uses the same concept but its own interface. PSR-15 packages work via a bridge:
php artisan make:middleware CheckAge
// Laravel's middleware signature (not PSR-15, but same pattern)
public function handle(Request $request, Closure $next): Response
{
if ($request->age < 18) {
return redirect('home');
}
return $next($request);
}
Slim
Slim is built on PSR-15 natively – any PSR-15 middleware works out of the box:
$app->add(new LoggingMiddleware($logger));
$app->add(new AuthMiddleware());
$app->add(new CorsMiddleware());
Mezzio (Laminas)
Mezzio provides a full PSR-15 pipeline with declarative configuration:
$app->pipe(LoggingMiddleware::class);
$app->pipe(AuthMiddleware::class);
$app->pipe(CorsMiddleware::class);
$app->pipe(BlogPostHandler::class);
Try It Yourself
git clone https://github.com/jonesrussell/php-fig-guide.git
cd php-fig-guide
composer install
composer test -- --filter=PSR15
See src/Http/Middleware/ for the blog API’s middleware stack.
What’s Next
Next up: PSR-18: HTTP Client – how to send HTTP requests the standard way, completing our HTTP stack.
Resources
Baamaapii 👋