Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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.

MiddlewareWhat it doesConfigure with
CorsMiddlewarePreflight (OPTIONS) handling and Access-Control-* headers on every response.Constructor args or ZEALPHP_CORS_ORIGINS
ETagMiddlewareWeak ETag on GET responses; returns 304 on If-None-Match match.None
CompressionMiddlewaregzip/deflate when client supports it. Skip if OpenSwoole’s built-in compression is on.None
RangeMiddlewareRFC 7233 Range requests: 206 Partial Content, multi-range, If-Range.None
SessionStartMiddlewareEager session start for first-time visitors. Without this, only returning visitors get sessions.ZEALPHP_SESSION_SECURE for HTTPS override
IniIsolationMiddlewareSnapshots 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:

route/admin.php — attach middleware to one route
<?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:

app.php — declare aliases before $app->run()
<?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:

route/admin.php — a group, and a nested group
<?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)
LayerOrder rule
Global stack (addMiddleware)First-registered is outermost / runs first.
Group middlewareWraps outside the route's own middleware.
Route middlewareFirst-listed is outermost; wraps outside the handler.
HandlerInnermost. 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:

using RequestIdMiddleware — id read back from the per-request memo
<?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():

a route that exposes the routing + middleware topology as JSON
<?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.

curl the live per-route demo

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:

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 with App::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.