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<Route>
*/
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:
- when a request is rejected,
- what gets recorded in the logs,
- 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.
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
andResponse
objects.