PSR-15 in Practice: Complete Guide to Middleware in PHP

09.09.2025

PSR-15 in Practice: Complete Guide to Middleware in PHP

In this article, we will take a detailed look at:

  • Components necessary to run middleware, i.e., a brief introduction
  • what middleware is and what role it plays,
  • PSR-15 interfaces and their practical implementations,
  • creating your own components step by step,
  • managing order and advanced scenarios,
  • best practices and mistakes to avoid.

Introduction - What We Need to Run Middleware

Middleware, as one of the PSR standards, needs other components to run:

  • Some handler that will process our PSR-7 request into response - we will have a very simple RouteHandler class
  • A Kernel being a PSR-15 compliant middleware Handler
  • An Application being a PSR-3 compliant DI container
  • A Route class responsible for a single route
  • A Route collection class responsible for a set of routes
  • A Router class that transforms request to response
  • A MiddlewareStack class that invokes middleware in a specific order

Simplified definitions of these classes are shown below

<?php
    class Response implements ResponseInterface
    {
        //detailed implementation is described in chapters 2 and 12 of the course
    }
readonly class RouteHandler
    {
        final public function __construct(private Closure $callback)
        {
        }
        public function dispatch(ServerRequestInterface $request): ResponseInterface
        {
            return ($this->callback)($request);
        }
    }

class Route
{
    public private(set) array $middlewareBefore = [];
    //analogous arrays for after and global middleware
    private readonly string $method;

    public function __construct(
        public string $path,
        string $method,
        public readonly RouteHandler $handler,
        public readonly ?string $name = null
    )
    {
        $this->method = strtoupper($method);
    }
    //for middleware purposes, the rest of the class is not very important
}

class RouteCollection implements \IteratorAggregate, \Countable
{
    /**
     * @var list&ltRoute&gt
     */
    private array $routes = [];

    public array $middlewareBefore {
        get {
            return array_values(
                array_unique(
                    array_merge(
                        ...array_map(
                            static fn(Route $route): array => $route->middlewareBefore,
                            $this->routes
                        )
                    )
                )
            );
        }
    }

    public function findRoute(RequestInterface $request): Route
    {
        $matcher = new AdvancedRouteMatcher();
        $matchingRoutes = array_filter(
            $this->routes,
            static fn (Route $route) => $matcher->matches($request, $route)
        );
        return array_values($matchingRoutes)[0] ?? throw new RouteNotFoundError(
            "No route found for " . $request->getMethod(
            ) . " " . $request->getUri()->getPath()
        );
    }

    //analogous arrays for other middleware types

    public function addRoute(Route $route): void
    {
        $this->routes[] = $route;

        if ($route->name) {
            $this->namedRoutes[$route->name] = $route;
        }
    }
}

class Router
{
   public function __construct(
        public private(set) RouteCollection $routes = new RouteCollection(),
    ) {
   }

   public function addRoute(
        Route $route
    ): self {
        $this->routes->addRoute($route);
        return $this;
    }

    public function dispatch(ServerRequestInterface $request, RequestHandlerInterface $next): ResponseInterface
    {
        $route = $this->routes->findRoute($request);
        $handler = $route->handler;

        $response = $handler->dispatch($request);
        return $next->handle($request->withAttribute("route_response", $response));
    }
}

What Actually Is Middleware?

Middleware works like a filter: it intercepts the request before it reaches the main application logic and can process, reject, or enrich it. Think of them as onion layers — each middleware wraps the next one, and the request passes through them in a specific order.

It is worth remembering that middleware is essentially any class that transforms a request object into a response. The PSR-15 standard defines a unified interface and middleware lifecycle, but its use is not mandatory. A good example is middleware in Laravel, which simply accepts any callable as a handler. This is because the request in Laravel does not have to be PSR-7 compliant. Additionally, from version 11, the framework introduced the Middleware helper, which handles three arrays: prepend, append and replace — which is a direct equivalent of the system shown in this article below.

Example of Middleware in Laravel

<?php
class CheckAuth
{
    final public function handle(\Illuminate\Http\Request $request, Closure $next): \Illuminate\Http\RedirectResponse
    {
        /** @var ?\App\Models\User $user */
        $user = $request->user();
        if (\!$user?->can("posts.write") ) {
            return to_route(route: "login");
        }

        return $next($request);
    }
}

Symfony is equally interesting. This framework natively supports the PSR-15 standard – so you can write middleware fully compliant with the MiddlewareInterface. At the same time, Symfony has long used its own approach based on event listeners and event subscribers that react to events such as kernel.request or kernel.response. This way you can choose between the "event" style and pure PSR-15, depending on the project needs.

<?php

use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\RouterInterface;

final class CheckAuthListener
{
    public function __construct(private RouterInterface $router) {}

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $user = $request->getUser(); // in practice, e.g., from Security component

        if (\! $user || \! $user->can("posts.write")) {
            $loginUrl = $this->router->generate("login");
            $event->setResponse(new RedirectResponse($loginUrl));
        }
    }
}

Example applications:

  • authentication and authorization,
  • logging and monitoring,
  • CORS handling,
  • caching,
  • modifying response headers.

Basic PSR-15 Interfaces

PSR-15 defines two main interfaces:

  • MiddlewareInterface – describes a single middleware component,
  • RequestHandlerInterface – allows passing the request further down the chain.

Building Your Own Middleware — Step by Step

Let us build simple middleware that handles requests through a router and catches exceptions

<?php
  final class RouterMiddleware implements MiddlewareInterface
  {
      public function __construct(private Router $router) {
      }

      public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
          try {
              return $this->router->dispatch($request, $handler);
          } catch (\Throwable $exception) {
              $request = $request->withAttribute("exception", $exception);
              return $handler->handle($request);
          }
      }
  }

This approach allows you to easily "wrap" any diagnostic logic without interfering with the main application code.

Global and Conditional Middleware

Middleware can be applied globally (for the entire application) or assigned only to selected routes. This way you can globally log response times and only check authorization on specific routes.

Order Matters

The diagram below perfectly shows why middleware order matters — because each of them works like an onion layer .

  • If CORS is placed at the very bottom, the client will get a denial even before going through authentication or logging.
  • If Auth Middleware comes before Logging Middleware, all rejected requests will be logged — which can be useful. But if you reverse the order, some attempts will never reach the logs.
  • If the Router Handler comes first, all security and access control would be bypassed.

So the order determines:

  1. when a request is rejected,
  2. what gets recorded in the logs,
  3. whether the response reaches the client with the required headers (e.g. CORS).

In short: middleware is not just a set of filters, but a pipeline where every element has its place in the hierarchy.

flowchart TD A[Request] --> B[CORS Middleware] B --> C[Auth Middleware] C --> D[Logging Middleware] D --> E[Router Handler] E --> F[Response] F --> D D --> C C --> B B --> G[Client Response]

Below, you can see implementation of MiddlewareStack - the class responsible for processing middleware in correct order

<?php

 class MiddlewareStack implements RequestHandlerInterface
{

    private array $middleware = [];
    private int $currentIndex = 0;

    public function __construct(
        private readonly Router $router,
        private Response $originalResponse = new Response()
    )
    {
    }
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        //check if router middleware caught exception - STOP\!\!
        $exception = $request->getAttribute("exception");
        if($exception \!== null) {
            throw $exception;
        }
        //get current middleware
        $middleware = $this->middleware[$this->currentIndex] ?? null;
        //if router executed main app code and no more middleware
        if ($this->currentIndex === count($this->middleware) && $this->routerExecuted) {
            return $request->withAttribute("route_response", $this->originalResponse);
        }
        //mark that router has executed
        if ($middleware instanceof RouterMiddleware)
        {
            $this->routerExecuted = true;
        }
        $this->currentIndex++;
        //process next middleware, since we call process on middleware, we will recursively call this handler
        return $middleware?->process($request, $this) ?? $this->originalResponse;
    }
}

Best Practices and Common Mistakes

  • Do not mix business logic with middleware – stick to the Single Responsibility principle.
  • Return Response in every branch – no response = error.
  • Test middleware independently – each component should be easy to unit test.
  • Document the order – in larger applications, it is easy to create chaos.

How to Test Middleware?

A key element of middleware operation is the order of their calls. We can write a simple test that checks whether middleware actually works in the expected order.

<?php
    class MiddlewareTest extends \PHPUnit\Framework\TestCase
    {
        public function setUp(): void
        {
            //mock middleware
            $this->middlewareClass = new class implements MiddlewareInterface {
                public function process(
                    ServerRequestInterface $request,
                    RequestHandlerInterface $handler
                ): ResponseInterface
                {
                    $response = $handler->handle($request);
                    return $response->withHeader("X-Middleware-Test", "true");
                }
            };

            //mock application configuration
            $config = $this->createMock(ConfigContract::class);
            $config->expects($this->any())
                ->method("get")
                ->with("middleware")
                ->willReturn([
                    "before_global" => [],
                    "global" => [],
                    "after_global" => [],
                ]);
            $app->set(ConfigContract::class, $config);
        }

     public function testMiddlewareBefore(): void
    {
        $app = Application::getInstance();
        $response = new Response();

        $handler = new RouteHandler(callback: fn() => $response);
        $app->withRoutes(function (Router $router) use ($handler) {
            $router->addRoute(
                new Route(
                    "/",
                    "GET",
                    $handler
                )->withMiddlewareBefore($this->middlewareClass::class)
            );
        });
        $response = $app->handle();
        $before = Config::get("middleware.before_global");
        //verify that middleware was added to array
        $this->assertEquals($before, $this->middlewareClass::class);
        //verify that middleware was executed
        $this->assertEquals("true", $response->getHeaderLine("X-Middleware-Test"));
    }
    }
<

1. Integration Tests

Middleware are components that heavily interfere with the entire application and should be tested in integration tests. In the example test, we create a very simple handler that just returns a response, and then check whether after executing middleware, the response is PSR-7 compliant

<?php
class RouterMiddlewareTest extends BaseTestCase
{
    public function setUp(): void
    {
        $app = Application::getInstance();
        $config = $this->createMock(ConfigContract::class);
        $config->expects($this->any())
            ->method("get")
            ->with("middleware")
            ->willReturn([
                "before_global" => [],
                "global" => [
                    RouterMiddleware::class
                ],
                "after_global" => [],
            ]);
        $app->set(ConfigContract::class, $config);
    }

    public function testMiddleware()
    {
        $app = Application::getInstance();
        $response = new Response()->withContent("hello from tests");
        $handler = new RouteHandler(callback: fn() => $response);
        $app->withRoutes(function (Router $router) use ($handler) {
            $router->addRoute(
                new Route(
                    "/",
                    "GET",
                    $handler
                )
            );
        });
        $response = $app->handle();
        $this->assertInstanceOf(Response::class, $response);
        $this->assertEquals("hello from tests", $response->getBody()->getContents());
    }
}

Summary

PSR-15 allows building modular applications that are easy to maintain and compatible with the entire PHP-FIG ecosystem. It is a standard worth knowing well and applying in every web project.

Remember: test order, log exceptions, separate business logic, document middleware

👉 This is just a fragment of the complete MasterPHP course\!

In the course, you will find complete PSR-15 implementation with examples:

  • creating global middleware,
  • dynamic enabling and disabling of components,
  • advanced order management,
  • modifications of Request and Response objects.
Check the full course