Middleware
Middleware wraps the dispatch. Each handler gets the request and a $next closure, does its work before
calling $next and again after — so a stack forms an onion around the controller, entered outside-in and
left inside-out.
Writing a Handler#
One method, from Pop\Middleware\MiddlewareInterface. Call $next($request) to continue and return its
value, or return something else and the rest of the stack never runs.
<?php
namespace App\Http\Middleware;
use Pop\Middleware\MiddlewareInterface;
class RequestLog implements MiddlewareInterface
{
public function handle(mixed $request, \Closure $next): mixed
{
$start = microtime(true);
$response = $next($request);
error_log($request->getUriString() . ' ' . round((microtime(true) - $start) * 1000) . 'ms');
return $response;
}
}
$request is typed mixed because run() resolves it per dispatchable: a controller using HttpTrait
hands over its Pop\Http\Server\Request, one using ConsoleTrait its Pop\Console\Console, and a closure
target gets a freshly built one for the runtime.
Registering Middleware#
The middleware config key takes a list of handlers, and each entry is either a class name string or an
instance:
<?php
return [
'middleware' => [
'App\Http\Middleware\RequestLog',
new App\Http\Middleware\RequireApiKey('X-Api-Key')
]
];
addMiddleware() does the same imperatively, and takes an optional name so the handler can be found or
removed again:
$app = new Pop\Application();
$app->addMiddleware('App\Http\Middleware\RequestLog');
$app->addMiddleware(new App\Http\Middleware\RequireApiKey('X-Api-Key'), 'api-key');
var_dump($app->middleware()->hasHandler('api-key')); // true
$app->removeMiddleware('api-key');
A class-name string is constructed with no arguments when the pipeline runs, so it only works for a handler whose constructor takes none. Anything that needs configuration — a header name, a list of exempt paths, a logger — is registered as an instance.
Where the Pipeline Sits#
run() decides between three paths after the router has matched and app.dispatch.pre has fired:
- If the application is down and the request carries no maintenance secret, the maintenance response runs.
- Otherwise, if the middleware manager holds any handlers, the dispatch is deferred into the pipeline.
- Otherwise, the dispatchable is invoked directly.
Two things follow from that ordering. A maintenance response never reaches a handler, since the check comes first. And middleware runs only when a route matched — a 404 or 405 is answered by the router before the pipeline is entered.
Within the pipeline, handlers run in registration order — the first registered is the outermost layer, so it enters first and leaves last:
$app = new Pop\Application([
'middleware' => [
'App\Http\Middleware\RequestLog',
'App\Http\Middleware\RequireApiKey'
]
]);
RequestLog enters, then RequireApiKey, then the controller action runs, then RequireApiKey finishes,
then RequestLog. The value $next() returns to a handler is whatever the layer below it returned, and at
the bottom that is the return value of the dispatch itself.
Middleware that needs the response should reach for the response object the controller shares. For a
controller route, $next() returns null; only a closure route target returns anything to the innermost
handler.
Short-Circuiting a Request#
A handler that does not call $next() ends the request there. Everything below it — the remaining handlers
and the dispatch — never runs, and what the handler returns becomes the pipeline's result.
<?php
namespace App\Http\Middleware;
use Pop\Http\Server\Response;
use Pop\Middleware\MiddlewareInterface;
class RequireApiKey implements MiddlewareInterface
{
protected string $header;
public function __construct(string $header = 'X-Api-Key')
{
$this->header = $header;
}
public function handle(mixed $request, \Closure $next): mixed
{
if (!$request->hasHeader($this->header)) {
$response = new Response();
$response->setBody(json_encode(['error' => 'Unauthorized']));
$response->send(401, ['Content-Type' => 'application/json']);
return $response;
}
return $next($request);
}
}
The handler sends the response itself, because nothing downstream will. Call setBody() first even when the
body is '' — see Requests & Responses.
Pop\Event\AbortException is the harder stop: thrown from a handler it unwinds the whole pipeline out to
run(), which returns quietly without firing app.error. Neither an outer handler's code after $next()
nor its terminate() runs.
Per-Route Middleware#
A route's config array takes its own middleware key, holding one handler or a list of them. It applies only
when that route matches:
<?php
return [
'middleware' => ['App\Http\Middleware\RequestLog'],
'routes' => [
'/' => [
'controller' => 'App\Http\Controller\IndexController',
'action' => 'index'
],
'/api/orders[/]' => [
'middleware' => 'App\Http\Middleware\RequireApiKey',
'controller' => 'App\Http\Controller\OrdersController',
'action' => 'index'
]
]
];
Route middleware is appended to the same manager the global list is in, so the global handlers stay outermost
and the route's own run inside them: measured on /api/orders, RequestLog enters, then RequireApiKey,
then the controller. A route's list keeps its own order.
Because both lists share one manager, per-route handlers accumulate if one application object runs more than once — Routing notes the same thing about forced routes. A web request builds one application and runs it once, so this shows up in tests and long-running workers rather than in production.
MIDDLEWARE_DISABLED turns either list off without editing config, which is what a debugging session wants.
Both the application's global check and the router's per-route check read it through Pop\App::env().
.env value |
App::middlewareDisabled() |
Global middleware |
Per-route middleware |
|---|---|---|---|
| unset | '' |
runs | runs |
false |
'' |
runs | runs |
all |
'all' |
skipped | skipped |
true |
'all' |
skipped | skipped |
route |
'route' |
runs | skipped |
| anything else | '' |
runs | runs |
MIDDLEWARE_DISABLED=true means the same as all, since Pop\App::env() coerces the literal and the
boolean maps to 'all'.
Write all, route or true in lower case — the coercion is case-sensitive, and anything else leaves
both lists running.
Terminable Middleware#
Pop\Middleware\TerminableInterface adds a second method, run after the whole pipeline has finished, with the
request and the pipeline's return value. It's where work that must not delay the response goes.
class RequestLog implements Pop\Middleware\MiddlewareInterface, Pop\Middleware\TerminableInterface
{
public function handle(mixed $request, \Closure $next): mixed
{
return $next($request);
}
public function terminate(mixed $request = null, mixed $response = null): void
{
error_log('finished ' . $request->getUriString());
}
}
terminate() runs for every terminable handler in the manager, in registration order, and it runs even when
an earlier handler short-circuited the request — a 401 from RequireApiKey still terminates RequestLog.
Register a handler as an instance when it needs to carry state from handle() to terminate(). A
class-name string is constructed once for each call, so the two see different objects.
PSR-15 Handlers#
Pop\Middleware\Psr15\MiddlewareAdapter wraps a Psr\Http\Server\MiddlewareInterface handler so it can sit
in the same queue as a native one, translating Pop's handle($request, $next) into PSR-15's
process($request, $handler):
$app = new Pop\Application();
$app->addMiddleware(new Pop\Middleware\Psr15\MiddlewareAdapter(new SomeThirdPartyMiddleware()));
Both halves of Pop's own HTTP pair are PSR-7 objects: Pop\Http\Server\Request implements
Psr\Http\Message\ServerRequestInterface and Pop\Http\Server\Response implements
Psr\Http\Message\ResponseInterface. The request the adapter passes in therefore satisfies the PSR-15
signature, and a wrapped handler can call getMethod(), getUri(), withHeader() and the rest on either
object directly.
What has to line up is the route target's return value. Pop\Middleware\Psr15\RequestHandler declares
ResponseInterface as its return type, so a closure route target has to return one.
A PSR-15 MiddlewareAdapter pairs with a closure route that returns a
Psr\Http\Message\ResponseInterface. run() says so up front if it is registered against a controller-class
route, before the queue is entered and before the controller is constructed.
A closure route target that returns a ResponseInterface completes cleanly, whether that is Pop's own
response object or one from a library such as nyholm/psr7:
$app = new Pop\Application();
$app->addMiddleware(new Pop\Middleware\Psr15\MiddlewareAdapter(new SomeThirdPartyMiddleware()));
$app->get('/status', function() {
return new Pop\Http\Server\Response(['code' => 200, 'body' => 'ok']);
});
Two shapes are yours to get right. A closure returning nothing fails on the way back out with a TypeError
from RequestHandler::handle(). And a PSR-15 middleware that never delegates returns its own response,
which is the short-circuit you want when that is deliberate.
Driving a Pop\Middleware\Manager yourself with process(), outside an application, works the same way and
lets you supply both the request object and the terminal dispatch closure.
Middleware exposes more than this page covers — driving Pop\Middleware\Manager::process() outside an
application, and the static Manager::terminate() — see the popphp README.
See Also#
- Applications & Bootstrap — where the pipeline sits in
run() - Routing — the route config array the
middlewarekey lives in - Events — the other way to wrap a request, and when to prefer it
- Requests & Responses — the request object a handler receives
- Error Handling — maintenance mode, which runs before middleware
- popphp README — the full middleware API surface