Implementing PSR-15 Middleware From Scratch: Understanding the Pipeline
AI generated
8.4
PHP · PSR-15 · Middleware
Implementing PSR-15 Middleware From Scratch
Understanding the pipeline behind the framework

Nearly every modern PHP framework promises a PSR-15 compatible middleware pipeline, yet what happens internally stays a black box for many developers. We build the pipeline step by step ourselves, without a framework, and use logging, auth and CORS middleware to show exactly how the onion model works technically.

16 min read RequestHandlerInterface MiddlewareInterface Onion Model PSR-7

1. What PSR-15 actually defines

Compared to other PHP-FIG standards, PSR-15 is surprisingly small. It defines exactly two interfaces, RequestHandlerInterface and MiddlewareInterface, and specifies how an incoming request travels through a chain of middleware down to a final handler. It builds on PSR-7, which provides the actual HTTP message objects such as ServerRequestInterface and ResponseInterface, and complements PSR-17 as a factory interface for creating new response objects.

Anyone working with Slim, Mezzio, Laminas or a custom PSR-15 implementation usually encounters the pipeline as a finished concept that simply works. That very smoothness is why many developers never understand why middleware needs to be registered in a specific order, or how a framework routes a request to the right place at all. Building it from scratch, with zero dependencies, makes that mechanism fully visible.

2. RequestHandlerInterface and MiddlewareInterface in detail

RequestHandlerInterface defines a single method, handle(), which accepts a ServerRequestInterface and returns a ResponseInterface. A handler is the endpoint of the pipeline, it produces a concrete response and passes nothing further along. In a typical application, this is the controller or route that actually loads data from a database and assembles a response.

MiddlewareInterface likewise defines only one method, process(), which additionally receives a RequestHandlerInterface alongside the request. That second parameter is the crucial difference from a plain handler, because a middleware can decide for itself whether and when to invoke that handler. It can modify the request beforehand, adjust the response afterward, or abort the entire chain by simply never calling the handler it was given.


<?php

declare(strict_types=1);

namespace Psr\Http\Server;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

interface RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface;
}

interface MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface;
}

3. The onion model: how a pipeline really executes

The common metaphor for PSR-15 pipelines is the onion. Every registered middleware wraps itself as another layer around the final handler at the core. The incoming request travels from the outside in through every layer until it reaches the core, and the response then travels the same path back out, through the same layers in reverse order.

Concretely: code a middleware runs before calling handler->handle() executes on the way in. Code after that call executes on the way out, after the core and every inner layer have already produced a response. This pattern is fundamentally different from a simple linear execution and explains why the registration order of middleware directly affects the behavior of the entire application.


Request
  -> Error handling (outer)
     -> CORS
        -> Auth
           -> Logging
              -> final handler (core)
           <- Logging (measure duration)
        <- Auth
     <- CORS (append headers)
  <- Error handling
Response

4. Building a minimal pipeline without a framework

The core of the from-scratch build is a class that itself implements RequestHandlerInterface and internally manages a queue of middleware. When handle() is called, the pipeline pulls the first middleware from the queue and passes itself along as the remainder of the chain. Once the queue is empty, the final handler is invoked directly and produces the actual response.

The trick lies in using clone: instead of maintaining a mutable pointer to the current position in the queue, every step creates a new, immutable copy of the pipeline with the queue shortened by one element. This avoids side effects if the same pipeline instance were theoretically reused, and is conceptually close to what libraries like relay/relay do internally.


<?php

declare(strict_types=1);

namespace App\Http;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * Minimal request handler that works through a queue of middleware
 * and finally hands off to the final handler.
 */
final class MiddlewarePipeline implements RequestHandlerInterface
{
    /** @var array<int, MiddlewareInterface> */
    private array $queue;

    public function __construct(
        private readonly RequestHandlerInterface $finalHandler,
        MiddlewareInterface ...$middleware,
    ) {
        $this->queue = $middleware;
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        if ($this->queue === []) {
            return $this->finalHandler->handle($request);
        }

        // Pull the next middleware off the queue
        $current = array_shift($this->queue);

        // Pass a clone of ourselves as "the rest of the pipeline"
        // to the next middleware, this is what creates the onion model
        $next = clone $this;
        $next->queue = $this->queue;

        return $current->process($request, $next);
    }
}

5. A first middleware of our own: logging

A logging middleware is the simplest starting point because it neither changes the request nor the response, it only observes. It measures the time before calling handler->handle(), lets the rest of the pipeline run, and afterward writes method, URI, status code and duration to a log.

For the measured duration to actually cover the entire remaining processing, the logging middleware needs to sit as far outward in the pipeline as possible, ideally right behind the error handling layer. If it sits too far inward, for example after the auth middleware, the timing for rejected requests goes missing from the log entirely, which later skews performance analysis.


<?php

declare(strict_types=1);

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;

final class LoggingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $start = microtime(true);

        // Pass the request on to the next layer of the pipeline
        $response = $handler->handle($request);

        $duration = round((microtime(true) - $start) * 1000, 2);
        $this->logger->info(sprintf(
            '%s %s -> %d (%sms)',
            $request->getMethod(),
            (string) $request->getUri(),
            $response->getStatusCode(),
            $duration,
        ));

        return $response;
    }
}

6. Auth middleware and deliberate short circuiting

An auth middleware demonstrates most clearly why the handler parameter in process() matters so much. It reads the Authorization header, validates the token, and then deliberately decides whether to call the given handler at all. If the token is invalid, it builds a 401 response directly via a PSR-17 response factory and returns it, never calling handler->handle() at all.

That is exactly short circuiting: every middleware registered after the auth middleware in the pipeline, and the final handler itself, never execute in that case. If the token is valid, the middleware passes the request onward enriched via withAttribute(), so downstream code can read the authenticated user without validating the token again.


<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class AuthMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly TokenValidatorInterface $validator,
        private readonly ResponseFactoryInterface $responseFactory,
    ) {
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $token = $request->getHeaderLine('Authorization');

        if (!$this->validator->isValid($token)) {
            // Abort the pipeline here on purpose, handler->handle()
            // is deliberately NOT called
            $response = $this->responseFactory->createResponse(401);
            $response->getBody()->write('Unauthorized');
            return $response;
        }

        return $handler->handle($request->withAttribute('user_token', $token));
    }
}

7. CORS middleware and why order matters

A CORS middleware calls handler->handle() first and only modifies the returned response afterward, appending access control headers. It works almost exclusively on the way back out of the onion, not on the way in, making it the counterpart to the auth middleware, which decides mostly on the way in.

Order is not a minor detail here: if CORS sits ahead of auth in the pipeline, meaning further outward, its headers still land on a 401 response from the auth middleware, because the response passes through the CORS layer on its way back out. If CORS sits behind auth instead, the headers are missing from every rejected request, and browsers cannot even read the error response in the frontend because the CORS headers are absent.


<?php

declare(strict_types=1);

namespace App\Http\Middleware;

final class CorsMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $response = $handler->handle($request);

        return $response
            ->withHeader('Access-Control-Allow-Origin', 'https://mironsoft.de')
            ->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    }
}

// Cors sits deliberately ahead of Auth: the CORS headers still
// need to land on 401 responses, otherwise the browser cannot read them
$pipeline = new MiddlewarePipeline(
    $finalHandler,
    new ErrorHandlingMiddleware($responseFactory, $logger),
    new CorsMiddleware(),
    new AuthMiddleware($validator, $responseFactory),
    new LoggingMiddleware($logger),
);

8. Error handling as its own middleware layer

Instead of scattering try/catch blocks across every single handler and middleware, a dedicated error handling middleware bundles that logic in exactly one place. It calls handler->handle() inside a try block and catches any Throwable that bubbles up from any inner layer of the pipeline.

For that to work, the error handling middleware needs to be registered as the outermost layer, meaning the very first middleware in the constructor list. Only then can it catch exceptions from truly any component further inward, including failures from the auth middleware or database access inside the final handler, and turn them into a clean, consistently formatted 500 response.


<?php

declare(strict_types=1);

namespace App\Http\Middleware;

final class ErrorHandlingMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly ResponseFactoryInterface $responseFactory,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        try {
            return $handler->handle($request);
        } catch (\Throwable $exception) {
            $this->logger->error($exception->getMessage(), [
                'exception' => $exception,
            ]);

            $response = $this->responseFactory->createResponse(500);
            $response->getBody()->write('Internal Server Error');
            return $response;
        }
    }
}

9. Limits of building it yourself, and when a framework makes more sense

The biggest advantage of building it yourself is testability: every middleware can be tested in isolation by passing a mock RequestHandlerInterface whose handle() method returns a fixed response. That way you test exclusively what a given middleware itself changes about the request or response, without assembling the entire pipeline.

For production applications, a minimal from-scratch pipeline typically lacks things like route specific middleware groups, automatic selection of the right PSR-17 factory, or optimizations for very long chains. Libraries such as relay/relay already offer the pure pipeline mechanics fully tested, and larger frameworks like Mezzio or Slim add routing and container integration on top. Building it yourself remains most valuable for understanding the mechanism and for very small, dependency light microservices.

Approach Dependency Control over the pipeline Recommendation
Own pipeline as in this article Only PSR-7, PSR-15, PSR-17 Complete, every line is known Learning projects, very small microservices
relay/relay One lightweight library High, swappable resolvers When only the pure pipeline mechanic is needed
Slim Framework Slim router and Slim app Medium, tied to Slim conventions Small to mid sized APIs without complex routing
Mezzio Laminas components Medium to high, many extension points Mid sized to large applications
Classic try/catch without PSR-15 None Low, logic spreads across the codebase Not recommended for growing codebases

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

PSR-15 Middleware: The Essentials at a Glance

Two interfaces

PSR-15 consists only of RequestHandlerInterface and MiddlewareInterface, everything else is an implementation detail.

Onion model

Code before handler->handle() runs on the way in, code after it runs on the way back out of the pipeline.

Short circuiting

A middleware can abort the pipeline by deliberately never calling handler->handle().

Order matters

CORS and error handling middleware usually belong as the outermost layer, ahead of the auth middleware.

11. FAQ: PSR-15 Middleware: The Essentials at a Glance

1What is the difference between PSR-15 and PSR-7?
PSR-7 defines the HTTP message objects themselves, meaning request, response and stream. PSR-15 specifies how those objects travel through a chain of middleware and a final handler.
2Do I need to implement PSR-15 myself, or is a library enough?
For production code, a tested library like relay/relay or a framework is usually enough. Building it yourself mainly pays off for understanding the mechanism behind it.
3What happens if a middleware never calls handler->handle()?
The pipeline stops right there, every subsequent middleware and the final handler never run. That is exactly what auth middleware uses for deliberate short circuiting.
4Why is it called the onion model?
Because every middleware sits like an onion layer around the next one. The request travels from outside in, and the response then travels back from inside out through the same layers.
5Can a middleware still change the response after handler->handle()?
Yes, that is exactly what the CORS middleware in this article does. It calls handler->handle() first and appends headers to the returned response afterward.
6Where should error handling middleware sit in the pipeline?
As the outermost layer, meaning registered first, so it can catch exceptions from every middleware further inward and from the final handler.
7Is PSR-15 tied to a specific framework?
No, PSR-15 is framework agnostic. It only requires PSR-7 and optionally PSR-17 compatible objects, and practically every modern PHP framework supports it.
8How do I test a single middleware in isolation?
You pass a mock of RequestHandlerInterface whose handle() method returns a fixed response, then check how the middleware changes the request or response.
9Why does the pipeline class use clone instead of an index pointer?
Both approaches work technically. Clone makes every pipeline instance immutable and avoids side effects if the same pipeline were reused in parallel.
10Is building it yourself worth it for a real project?
For very small microservices without other dependencies, definitely. For growing applications, a tested library like relay/relay or a framework is the safer choice.