Middleware: The Wrap
Middleware is airport security between the gate and the plane. You don't write it for every flight. You nod at the TSA agent.
You will learn
- What PSR-15 middleware actually is (in 30 seconds)
- The six built-ins ZealPHP ships with — and when each one matters
- How to write a custom middleware in about 10 lines
- Why the first middleware you register is the outermost wrapper that runs first
- How to attach middleware to one route — the
middleware:option, named aliases, and route groups
What middleware is
A middleware is a function that gets the request before your handler, and gets the response after. It can short-circuit the request (returning 401 before authentication even reaches your route), add a header to every response (compression, CORS, ETag), measure timing, log, anything that should apply to many routes rather than one.
ZealPHP uses the PSR-15 middleware shape. A middleware implements:
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface;
You either return a response yourself (short-circuit) or call $handler->handle($request)
to delegate to the next middleware in the chain. Same shape Slim, Symfony, Laravel (via adapters),
and most modern PHP frameworks use.
The six built-ins
ZealPHP ships these out of the box. Most apps register the first four.
| Middleware | What it does | Configure with |
|---|---|---|
CorsMiddleware | Preflight (OPTIONS) handling and Access-Control-* headers on every response. | Constructor args or ZEALPHP_CORS_ORIGINS |
ETagMiddleware | Weak ETag on GET responses; returns 304 on If-None-Match match. | None |
CompressionMiddleware | gzip/deflate when client supports it. Skip if OpenSwoole’s built-in compression is on. | None |
RangeMiddleware | RFC 7233 Range requests: 206 Partial Content, multi-range, If-Range. | None |
SessionStartMiddleware | Eager session start for first-time visitors. Without this, only returning visitors get sessions. | ZEALPHP_SESSION_SECURE for HTTPS override |
IniIsolationMiddleware | Snapshots php.ini changes per request so ini_set() doesn’t leak. | ZEALPHP_INI_ISOLATE=1 |
Register them in app.php before $app->run():
use ZealPHP\Middleware\CorsMiddleware;
use ZealPHP\Middleware\ETagMiddleware;
use ZealPHP\Middleware\SessionStartMiddleware;
$app->addMiddleware(new CorsMiddleware());
$app->addMiddleware(new ETagMiddleware());
$app->addMiddleware(new SessionStartMiddleware());
Execution order
graph TB
REQ[Request arrives] --> A
subgraph A_wrap["A (first registered = outermost)"]
A[A.process] --> B_in
subgraph B_wrap["B"]
B_in[B.process] --> C_in
subgraph C_wrap["C (last registered = innermost)"]
C_in[C.process] --> RM["ResponseMiddleware
match route + invoke handler"]
end
end
end
RM --> RES[Response emitted]
style A_wrap fill:#fffbeb,stroke:#f59e0b,stroke-width:2px
style C_wrap fill:#ecfdf5,stroke:#059669,stroke-width:2px
style RM fill:#ecfdf5,stroke:#059669
You register A, B, C. The stack ZealPHP builds is A wraps B wraps C wraps
ResponseMiddleware. At request time, A runs first. The first middleware
you add is the outermost wrapper — same convention as Slim, Express, Laravel.
This means: register the outer-most concerns first, the inner-most last. CORS handles every response including 404s — register it first. Session handling is closest to the route handler — register it last.
Writing your own
Here’s a complete rate-limit-header middleware in 10 lines:
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
class RateLimitHeader implements MiddlewareInterface {
public function __construct(private int $limit = 60) {}
public function process($request, RequestHandlerInterface $handler): ResponseInterface {
$response = $handler->handle($request);
return $response
->withHeader('X-RateLimit-Limit', (string)$this->limit)
->withHeader('X-RateLimit-Remaining', (string)($this->limit - 1));
}
}
// in app.php
$app->addMiddleware(new RateLimitHeader(100));
The pattern: call $handler->handle(), modify the returned response, return it.
For short-circuit behavior (e.g., auth that returns 401 before the route runs), build a response
yourself with new \OpenSwoole\Core\Psr\Response(...) and return it without calling
$handler->handle() at all.
Per-route middleware: the wrap, but scoped
Everything so far wraps every route. But "airport security on every flight" is overkill — your
public marketing pages don't need the auth check that your /admin dashboard does. ZealPHP lets
you attach middleware to a single route, or to a group of routes sharing a
prefix. It's the same idea as Slim's route middleware, Laravel's ->middleware(), or Hyperf's
#[Middleware] attribute — named, ordered chains you opt routes into.
1. The middleware: option
Pass a middleware: list to route() (also nsRoute(),
nsPathRoute(), patternRoute()). Entries are middleware instances, alias strings, or
a mix. Routes without the option are byte-for-byte unchanged — it's purely additive:
<?php
use ZealPHP\App;
use ZealPHP\Middleware\RequestIdMiddleware;
use ZealPHP\Middleware\IpAccessMiddleware;
$app = App::instance();
// 'auth' + 'request-id' are named aliases (declared below);
// IpAccessMiddleware is passed as a ready instance. They combine.
$app->route('/admin/users',
fn() => \App\Models\User::all(),
middleware: ['auth', 'request-id', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']])],
methods: ['GET']);
// A sibling route with NO middleware — proves scoping. It pays zero
// added cost and never sees the auth check above.
$app->route('/health', fn() => ['ok' => true]);
You can also pass middleware via the array-options form ['middleware' => [...]]. If you use
both, they combine: array-option entries run first (outermost), then the named-arg entries.
2. Name your middleware once with App::middlewareAlias()
Repeating new RequestIdMiddleware() at every route gets old. Register a named alias
once at boot and reference it by string everywhere — exactly like Traefik's named middleware or Laravel's
route-middleware aliases:
<?php
use ZealPHP\App;
use ZealPHP\Middleware\RequestIdMiddleware;
use ZealPHP\Middleware\HeaderMiddleware;
// A ready instance — reused as-is.
App::middlewareAlias('request-id', new RequestIdMiddleware());
// A factory callable — runs ONCE at App::run(), the resulting instance
// is shared across every request that uses the alias.
App::middlewareAlias('demo-header', fn() => new HeaderMiddleware([
'set' => ['X-Demo-Route' => 'route-level'],
]));
// Parameterised reference, Laravel-style: 'throttle:120' calls the
// factory with the comma-split args, i.e. $factory('120').
// Note: RateLimitMiddleware requires a Store table named 'rate_limit' created before App::run()
App::middlewareAlias('throttle', fn(string $rpm = '60') => new \ZealPHP\Middleware\RateLimitMiddleware(limit: (int) $rpm));
// ... later: middleware: ['throttle:120']
3. Group routes that share a chain with $app->group()
When a whole section of your app shares a prefix and a middleware chain — every /admin/*
route needs auth + admin-only — wrap them in a group. The callback receives a
ZealPHP\RouteGroup whose route() / nsRoute() / group()
mirror App's, prepending the prefix and the shared middleware. Groups nest:
<?php
// group(string $prefix, array|callable $middleware = [], ?callable $registrar = null)
$app->group('/admin', ['auth', 'admin-only'], function ($g) {
// -> GET /admin/users, wrapped by auth -> admin-only -> handler
$g->route('/users', fn() => \App\Models\User::all());
// Nested: /admin/audit/recent, wrapped by
// auth -> admin-only -> audit-log -> handler
$g->group('/audit', ['audit-log'], function ($g) {
$g->route('/recent', fn() => \App\Models\Audit::recent());
});
});
// Middleware may be omitted — group('/admin', fn($g) => ...) is also valid.
4. The order, pinned crisply
The chain wraps from the outside in, and the response unwinds in reverse:
global → group → route → handler
(first-registered (first-listed
= outermost) = outermost)
| Layer | Order rule |
|---|---|
Global stack (addMiddleware) | First-registered is outermost / runs first. |
| Group middleware | Wraps outside the route's own middleware. |
| Route middleware | First-listed is outermost; wraps outside the handler. |
| Handler | Innermost. Runs last on the way in, first on the way out. |
A middleware that returns a response without calling $handler->handle() short-circuits
everything inside it — a 403 guard or a redirect stops before the handler ever runs.
5. A concrete example: RequestIdMiddleware
ZealPHP ships ZealPHP\Middleware\RequestIdMiddleware — it assigns or propagates a request
correlation id and echoes it on the response header. It's the textbook stateless route middleware:
<?php
use ZealPHP\App;
use ZealPHP\Middleware\RequestIdMiddleware;
use ZealPHP\RequestContext;
// Constructor: (string $headerName = 'X-Request-Id', bool $trustInbound = true)
// trustInbound: propagate an inbound X-Request-Id if present, else mint a
// fresh one (bin2hex(random_bytes(16)) = 32 hex chars).
App::middlewareAlias('request-id', new RequestIdMiddleware());
$app->route('/orders/{id}', function ($id) {
// The middleware stored the id in the per-request memo — read it back.
$rid = RequestContext::once('request_id', fn() => null);
return ['order' => $id, 'request_id' => $rid];
}, middleware: ['request-id']);
The id lands in the per-request memo, so any handler reads it with
RequestContext::once('request_id', fn() => null) (or checks
RequestContext::has('request_id')). Because the id lives in RequestContext — not on
the middleware object — it's coroutine-safe under concurrency.
6. See your chains: describeRoutes() + the visualizer
Wondering which middleware actually wraps a route? $app->describeRoutes() returns the whole
topology — the global chain (in execution order, ending with ResponseMiddleware (router)), the
registered aliases, and every route with its resolved middleware list. It works before and after
run():
<?php
// route/admin.php — the demo wires this at /demo/middleware/visualize
$app->route('/__routes', fn() => $app->describeRoutes());
// Shape:
// {
// "global": ["CorsMiddleware", ..., "ResponseMiddleware (router)"],
// "aliases": ["request-id", "demo-header", ...],
// "routes": [{ "methods": ["GET"], "path": "/admin/users",
// "middleware": ["auth", "request-id", "IpAccessMiddleware"],
// "handler": "Closure" }, ...]
// }
The website renders this as a Traefik-dashboard-style chain view at
/middleware#visualizer.
These endpoints are live in this very app (route/middleware.php). Watch the headers change per route:
# Route WITH ['request-id','demo-header'] — note BOTH headers
curl -i http://localhost:8080/demo/middleware/route-level
# X-Request-Id: 3f9c... (32 hex chars, minted by RequestIdMiddleware)
# X-Demo-Route: route-level (stamped by the demo-header alias)
# body echoes the same request_id, read from the per-request memo
# Sibling route with NO middleware — proves scoping (no X-Demo-Route)
curl -i http://localhost:8080/demo/middleware/plain
# A guard short-circuits with 403 — the handler never runs
curl -i http://localhost:8080/demo/middleware/blocked
# A route group sharing one header middleware (X-Demo-Group)
curl -i http://localhost:8080/demo/mwgroup/alpha
curl -i http://localhost:8080/demo/mwgroup/beta
# The whole topology as JSON
curl -s http://localhost:8080/demo/middleware/visualize | jq
Or open the rendered chain view: /middleware#visualizer.
You write $app->group('/admin', ['auth'], fn($g) => $g->route('/users', $h, middleware: ['rate-limit'])). In what order do the layers wrap the handler?
When NOT to write middleware
If the logic applies to one route, it goes in the handler. If it’s really tangled with request-specific data, it goes in the handler. If you find yourself reaching for middleware to validate a single form, you’ve over-engineered — just validate in the handler and return 422.
Middleware shines for cross-cutting concerns: things every route benefits from (CORS, logging, sessions) or things you can turn on/off as a feature flag (compression, rate-limiting, auth-required gates).
Try it live
The demo app registers CORS + ETag + Session-start + Range. See the headers in action:
- CORS:
Access-Control-*headers - ETag: 304 Not Modified on repeat
- Gzip compression:
Content-Encoding: gzip
You register middleware in the order: A, B, C. A request arrives. Which middleware sees the inbound request first?
Key Takeaways
- A middleware is a function that wraps every request:
(request, $handler) => response. - ZealPHP follows PSR-15 — same shape as Slim, Symfony, modern Laravel.
- Six built-ins: CORS, ETag, Compression, Range, SessionStart, IniIsolation.
- Register order: first-added is outermost and runs first — register CORS/compression before session/auth.
- Scope middleware per route with the
middleware:option, name chains withApp::middlewareAlias(), and share a prefix + chain with$app->group(). - Order: global → group → route → handler (first-registered / first-listed = outermost); a guard that skips
$handler->handle()short-circuits. - Alias factories run once at boot and share one stateless instance across all coroutines — per-request state lives in
$g/RequestContext. - Inspect every chain with
$app->describeRoutes()or the /middleware#visualizer page.