Pop PHP
The Basics

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.

PHPapp/src/Http/Middleware/RequestLog.php
<?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:

PHPapp/config/app.http.php
<?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:

PHP
$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:

  1. If the application is down and the request carries no maintenance secret, the maintenance response runs.
  2. Otherwise, if the middleware manager holds any handlers, the dispatch is deferred into the pipeline.
  3. 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:

PHP
$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.

PHPapp/src/Http/Middleware/RequireApiKey.php
<?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:

PHPapp/config/app.http.php
<?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.

PHP
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):

PHP
$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:

PHP
$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#