Middleware
ZealPHP uses PSR-15 middleware, globally with $app->addMiddleware() or per-route with the middleware: option. The first added (or listed) runs outermost — first to process the request, last to process the response. Middleware always returns a Psr\Http\Message\ResponseInterface — handlers inside use the universal return contract; ResponseMiddleware coerces handler returns to PSR-7 before your middleware sees them. See the live middleware visualizer below for a picture of every route's chain.
Built-in middleware
| Class | Apache / nginx parity | What it does |
|---|---|---|
CorsMiddleware | n/a (modern browser feature) | CORS preflight + Access-Control headers on every response |
ETagMiddleware | Apache FileETag, nginx etag on | Generates W/"md5" ETag, returns 304 on cache hit |
CompressionMiddleware | Apache mod_deflate, nginx gzip on | Reference gzip/deflate; runtime compression is handled by OpenSwoole by default |
RangeMiddleware | HTTP/1.1 RFC 7233 (universally expected) | Accept-Ranges: bytes; 206 for single/multi-range; 416 for unsatisfiable |
SessionStartMiddleware | n/a (PHP-native sessions) | Eagerly starts a session and sends Set-Cookie for new visitors |
RequestIdMiddleware | n/a (request correlation) | Assigns/propagates X-Request-Id; stores it in the per-request memo so handlers can read it |
IniIsolationMiddleware | n/a (long-running runtime concern) | Snapshots and restores ini_set() changes per request |
CharsetMiddleware | Apache AddDefaultCharset / AddCharset | Appends ; charset=utf-8 to text-ish response Content-Type |
CacheControlMiddleware | Apache <FilesMatch> Header set Cache-Control | Extension-keyed Cache-Control: max-age=N, public for static assets |
ExpiresMiddleware | Apache mod_expires, nginx expires 30d | Adds Expires: header by content type |
HeaderMiddleware | Apache mod_headers (Header set/add/unset) | Declarative response-header manipulation with conditional variants |
BasicAuthMiddleware | Apache AuthType Basic, nginx auth_basic | HTTP Basic Auth: htpasswd file or callback verifier |
IpAccessMiddleware | Apache Allow from / Deny from | CIDR allow/deny lists with allow-first or deny-first ordering |
RateLimitMiddleware | nginx limit_req | Sliding-window request rate limiter backed by Store (cross-worker) |
ConcurrencyLimitMiddleware | nginx limit_conn | In-flight concurrent-request cap backed by Counter |
BlockPhpExtMiddleware | Apache RewriteRule ^(.+)\.php$ - [F] | Refuses *.php URLs with 404 (for extensionless-only public surfaces) |
MimeTypeMiddleware | Apache AddType / ForceType | Sets/overrides Content-Type on non-static responses by extension or pattern |
BodyRewriteMiddleware | Apache mod_substitute (Substitute s/x/y/) | Single-line regex substitution on response body |
HostRouterMiddleware | nginx server_name a.com b.com | Dispatches per-host routes inside one ZealPHP instance |
ContentEncodingMiddleware | Apache mod_mime AddEncoding | Sets Content-Encoding from URL file suffixes (e.g. .gz, .br) |
ContentLanguageMiddleware | Apache mod_mime AddLanguage | Sets Content-Language from URL file suffixes (e.g. .en, .fr) |
MergeSlashesMiddleware | Apache MergeSlashes On, nginx merge_slashes | Collapses runs of consecutive slashes in the request path before routing |
RequestHeaderMiddleware | Apache mod_headers RequestHeader | set / append / unset on inbound request headers before handlers run |
ReturnMiddleware | nginx return directive | Unconditionally returns a fixed response — pair with ScopedMiddleware |
ScopedMiddleware | Apache <Location> / <LocationMatch> containers | Apply another middleware only to matching request paths |
SetEnvIfMiddleware | Apache mod_setenvif / BrowserMatch | Set request "env" vars in $g->server when a request attribute matches a regex |
BodySizeLimitMiddleware | nginx client_max_body_size, Apache LimitRequestBody | Rejects oversized request bodies with 413 Content Too Large |
RedirectMiddleware | Apache mod_alias (Redirect / RedirectMatch) | Declarative URL redirects — prefix and regex rules, first match short-circuits |
RefererMiddleware | nginx valid_referers / $invalid_referer | Hotlink protection — refuses requests whose Referer is not in the allowed set |
CsrfMiddleware | n/a (framework-level) | Double-submit CSRF protection for state-mutating requests |
HealthCheckMiddleware | n/a (ops concern) | Short-circuits on health-check paths (default /healthz); returns 200/503 JSON |
LocationHeaderMiddleware | n/a (proxy port rewrite) | Rewrites the port in an outbound Location header to a configured value — useful behind a non-standard-port proxy. Note: zero live registrations in the built-in app; wire it manually if you need port-rewriting behind a proxy. |
$app->addMiddleware(new CorsMiddleware()); // outermost — handles preflight
$app->addMiddleware(new ETagMiddleware()); // generates ETag
$app->addMiddleware(new CustomAuthMiddleware()); // your custom middleware
// ResponseMiddleware is always innermost (built-in)
Server-level Apache directives map to App::$* static properties + fluent setters (e.g., App::clientIp(), App::canonicalHost(), App::$trusted_proxies, App::$access_log_format). See legacy-apps for the full server-level configurability matrix.
Per-route middleware
Global middleware wraps every request. When a policy belongs to a handful of routes — auth on /admin, a rate limit on one endpoint, a correlation id on your job API — attach it per route instead. The reference point here is Hyperf (a Swoole app server with #[Middleware] on routes and per-coroutine context), not Traefik. Traefik is an L7 edge proxy that forwards to backends and never runs your code; ZealPHP per-route middleware competes with Slim / Laravel / Hyperf route middleware. We borrow Traefik's vocabulary — named middleware, ordered chains — on top of Hyperf's coroutine runtime model.
The differentiator: ZealPHP middleware runs inside the request lifecycle. It can read/write $g, touch the session, run a Store/Redis query, spawn go() coroutines, and short-circuit with real application logic — none of which an edge proxy can do. Because per-route middleware runs after route matching, path-rewriters (Traefik StripPrefix / AddPrefix / ReplacePath) stay global / pre-match; auth, headers, rate-limit, redirect, IP-allow-list, and compression are clean per-route fits.
The middleware: route option
Every route registrar — route(), nsRoute(), nsPathRoute(), patternRoute() — accepts a middleware: list of MiddlewareInterface instances and/or alias strings. It's purely additive and backward-compatible: routes without middleware: are byte-for-byte unchanged (a zero-cost fast path).
use ZealPHP\Middleware\IpAccessMiddleware;
$app->route('/admin/users',
fn() => User::all(),
methods: ['GET'],
middleware: ['auth', 'request-id', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']])],
);
// Two ways to declare middleware — they COMBINE.
// Array-option entries run first (outermost), then named-arg entries.
$app->route('/reports',
['middleware' => ['audit-log']], // array option → outermost
fn() => Report::all(),
middleware: ['request-id'], // named arg → inner of the two
);
Named aliases — App::middlewareAlias()
Register a short name once, reference it from any route by string. Pass a ready instance (reused as-is) or a factory callable that returns a MiddlewareInterface. Factories run once at App::run() (boot, single-coroutine); the resulting instance is shared across every request that uses the alias. A parameterised reference like 'throttle:120' calls the factory with the comma-split args (fn('120')) — the Laravel 'throttle:60,1' shape.
use ZealPHP\Middleware\{BasicAuthMiddleware, IpAccessMiddleware, RateLimitMiddleware};
App::middlewareAlias('auth', fn() => new BasicAuthMiddleware(htpasswdFile: __DIR__ . '/.htpasswd'));
App::middlewareAlias('admin-only', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']]));
App::middlewareAlias('throttle', fn($n = '60') => new RateLimitMiddleware(limit: (int)$n));
$app->route('/api/heavy', fn() => Heavy::run(), middleware: ['throttle:120']);
Stateless contract: one alias instance serves every concurrent coroutine, so middleware objects must hold no per-request state. Put request-scoped data in $g (the request context / memo), never on the middleware instance — exactly how RequestIdMiddleware stashes its id in $g->memo['request_id'].
Path-sensitive guards: the router matches on the normalized path (collapsed //, decoded traversal, AllowEncodedSlashes), which it writes to $g->server['REQUEST_URI']. A per-route middleware that keys off the URL should read $g->server['REQUEST_URI'] rather than $request->getUri()->getPath() — the PSR-7 request still carries the original, un-normalized path.
Route groups — $app->group()
Share a prefix and a middleware chain across a block of routes. The callback receives a ZealPHP\RouteGroup whose route()/nsRoute()/nsPathRoute()/patternRoute()/group() mirror App's — prepending the prefix and prepending the group's shared middleware. Group middleware wraps outside each route's own middleware, which wraps outside the handler. Groups nest. The middleware list may be omitted entirely: group('/admin', fn($g) => ...).
$app->group('/admin', ['auth', 'admin-only'], function ($g) {
$g->route('/users', fn() => User::all());
$g->group('/audit', ['audit-log'], function ($g) { // → /admin/audit/recent
$g->route('/recent', fn() => Audit::recent());
});
});
patternRoute() inside a group does not auto-apply the prefix (a raw regex is ambiguous to prefix) — but the group's shared middleware does still apply.
Path-scoped middleware — App::when()
The centralized, "think like Traefik" way to apply middleware to a slice of URLs — and the one mechanism that also covers the ZealAPI layer. Every request (a route handler or an api/**.php file) flows through the same stack, and api/admin/x.php is reached by the URL /api/admin/x — so you scope by path and it just works. There is no separate "api middleware".
App::when('/', ['request-id']); // every request
App::when('/admin', ['auth', 'admin-only']); // /admin and /admin/* (routes)
App::when('/api/admin', ['auth']); // api/admin/*.php endpoints
App::when('/api/admin/users/delete', ['audit']); // a single api endpoint
App::when('#^/api/v\d+/#', new CorsMiddleware()); // a PCRE scope
Scope syntax: a literal path prefix by default (matched on segment boundaries — /admin matches /admin and /admin/x but not /administrators); a PCRE when the string starts with #. '/' matches everything. It accepts instances, alias strings (incl. 'throttle:120'), or a list, and composes in registration order — first registered is outermost. It runs after path normalization and after CORS/OPTIONS handling, so a when auth guard never blocks a preflight.
Co-located alternative — an api file's own $middleware: an api/**.php file can declare its middleware inline (read like $get/$post), which runs innermost — after any App::when scope, closest to the handler.
$middleware = ['confirm-token']; // runs after App::when('/api/admin')['auth']
$delete = function () {
return ['deleted' => true];
};
Ordering
One rule, pinned crisply: first-registered (or first-listed) is outermost — it processes the request first and the response last.
global → App::when → group / route → api in-file → handler
Within each band, the first entry you add/list is the outer wrap. The response unwinds in reverse. A middleware that returns without calling the handler (a 403, a redirect) short-circuits the chain before the handler runs. This is consistent with the global stack: OpenSwoole's StackHandler::add() prepends, and the array_reverse at run() means the first middleware you add is outermost — the first to run.
Coroutine-safety status
Per-route middleware rides on ZealPHP's coroutine-safety substrate, so what's safe depends on what each middleware touches:
| Status | Middleware / pattern |
|---|---|
| Coroutine-safe now | RateLimitMiddleware + ConcurrencyLimitMiddleware (backed by Store / Counter shared memory) |
| Feasible now | ForwardAuth, request-level CircuitBreaker, Retry — on hooked backends (the ZealPHP\HTTP coroutine client, Store, the pooled Redis client) |
| Blocked | DB-backed auth/session middleware — waits on the per-coroutine DB connection pool. pdo_pgsql still blocks the worker (needs a native Postgres coroutine client) |
Live middleware visualizer
$app->describeRoutes() returns the whole topology — the global chain (ending with ResponseMiddleware (router)), the App::when path scopes, the registered aliases, and every route's methods / path / middleware / handler. It works before and after run(); the demo serves it live at GET /demo/middleware/visualize. Below is this server rendering it — think like Traefik's dashboard, but for your own in-process routes.
$map = $app->describeRoutes();
// [
// 'global' => ['CorsMiddleware', 'ETagMiddleware', 'ResponseMiddleware (router)'],
// 'aliases' => ['auth', 'request-id', 'throttle'],
// 'when' => [['scope' => '/api/admin', 'middleware' => ['BasicAuthMiddleware']]],
// 'routes' => [
// ['methods' => ['GET'], 'path' => '/admin/users',
// 'middleware' => ['auth', 'request-id', 'IpAccessMiddleware'], 'handler' => 'Closure'],
// ],
// ]
Global stack every request
Path scopes App::when() — first registered = outermost
/api/secured→
HeaderMiddleware→ matched handler
/api/blocked→
ReturnMiddleware→ matched handler
/demo/scoped→
HeaderMiddleware→ matched handler
Per-route chains 4 routes
/demo/middleware/route-level→
RequestIdMiddleware→HeaderMiddleware→ Closure
/demo/middleware/blocked→
ReturnMiddleware→ Closure
/demo/mwgroup/alpha→
HeaderMiddleware→ Closure
/demo/mwgroup/beta→
HeaderMiddleware→ Closure
Aliases
All routes 206 total
| Methods | Path | Route middleware | Handler |
|---|---|---|---|
| GET | /phpinfo |
— | Closure |
| GET | /json |
— | Closure |
| GET | /raw/bench |
— | Closure |
| GET | /install.sh |
— | Closure |
| GET | /bench-install.sh |
— | Closure |
| GET | /bench/template |
— | Closure |
| GET | /_contract/include/echo-only |
— | Closure |
| GET | /_contract/include/status |
— | Closure |
| GET | /_contract/include/array |
— | Closure |
| GET | /_contract/include/string |
— | Closure |
| GET | /_contract/include/echo-then-return |
— | Closure |
| GET | /_contract/include/generator |
— | Closure |
| GET | /_contract/include/echo-then-generator |
— | Closure |
| GET | /_contract/include/closure-param |
— | Closure |
| GET | /_contract/include/no-leading-slash |
— | Closure |
| GET | /_contract/include/server-self |
— | Closure |
| GET | /_contract/include/traversal |
— | Closure |
| GET | /_contract/include/missing |
— | Closure |
| GET | /_contract/includefile/legacy-alias |
— | Closure |
| GET | /_contract/render/echo-only-bc |
— | Closure |
| GET | /_contract/render/status-passthrough |
— | Closure |
| GET | /_contract/render/array-passthrough |
— | Closure |
| GET | /_contract/render/generator-passthrough |
— | Closure |
| GET | /_contract/render-to-string/echo |
— | Closure |
| GET | /_contract/render-to-string/generator |
— | Closure |
| GET | /_contract/render-to-string/array |
— | Closure |
| GET | /_contract/render-stream/echo |
— | Closure |
| GET | /_contract/render-stream/generator |
— | Closure |
| GET | /_contract/render-stream/closure-param |
— | Closure |
| GET | #^/_contract/status/(?P<code>_?[0-9]+)$# |
— | Closure |
| GET | /_contract/co-state |
— | Closure |
| GET | /__error_test/throw-not-found |
— | Closure |
| GET | /__error_test/throw-exception |
— | Closure |
| GET | /__error_test/teapot |
— | Closure |
| GET | /__error_test/handler-self-throws |
— | Closure |
| GET | /__error_test/array-via-410 |
— | Closure |
| GET | /__error_test/generator-via-422 |
— | Closure |
| GET | /__error_test/handler-catches-warning |
— | Closure |
| GET | /__error_test/restore-pops-back-to-previous |
— | Closure |
| GET | /__error_test/restore-beyond-empty |
— | Closure |
| GET | /__error_test/slow-handler-set |
— | Closure |
| GET | /__error_test/fast-trigger |
— | Closure |
| GET | /__error_test/exception-handler-echo |
— | Closure |
| GET | /__error_test/shutdown-echo |
— | Closure |
| GET | /__error_test/shutdown-status |
— | Closure |
| GET | /__error_test/shutdown-order |
— | Closure |
| GET | /__error_test/shutdown-throws |
— | Closure |
| GET | /__error_test/shutdown-counter |
— | Closure |
| GET | /__error_test/shutdown-counter-read |
— | Closure |
| GET | /__error_test/error-reporting-set |
— | Closure |
| GET | /__error_test/error-reporting-read |
— | Closure |
| GET | /__error_test/error-reporting-roundtrip |
— | Closure |
| GET | /__error_test/suppressed-notice |
— | Closure |
| GET | /__error_test/html-handler-wins |
— | Closure |
| GET | /__error_test/header-leak-contenttype |
— | Closure |
| GET | /__error_test/header-leak-custom |
— | Closure |
| GET | /__error_test/header-leak-401-preserves-www-auth |
— | Closure |
| GET | /__error_test/header-leak-location-survives |
— | Closure |
| GET | /parity/request-headers |
— | Closure |
| GET | /parity/response-headers |
— | Closure |
| GET | /parity/header-remove |
— | Closure |
| GET | /parity/headers-sent |
— | Closure |
| GET | /parity/setrawcookie |
— | Closure |
| GET | /parity/header-status |
— | Closure |
| GET | /parity/header-code |
— | Closure |
| GET | /parity/header-status-600 |
— | Closure |
| GET | /parity/header-status-reason |
— | Closure |
| GET | /parity/header-status-superseded |
— | Closure |
| GET | /parity/apache-env |
— | Closure |
| GET | /parity/virtual |
— | Closure |
| GET | /parity/safe-stubs |
— | Closure |
| GET | /parity/is-uploaded |
— | Closure |
| GET | /parity/ob-flush |
— | Closure |
| GET | /parity/path-info |
— | Closure |
| GET | /badge/downloads |
— | Closure |
| POST | /api/chat |
— | Closure |
| GET | /api/chat/status |
— | Closure |
| POST | /api/convert |
— | Closure |
| GET | /data/{id} |
— | Closure |
| GET | /demo/inject/url/{id} |
— | Closure |
| GET | /demo/inject/url-request/{id} |
— | Closure |
| GET | /demo/inject/url-response/{id} |
— | Closure |
| GET | /demo/inject/request-only |
— | Closure |
| GET | /demo/inject/all/{id} |
— | Closure |
| GET | /demo/inject/defaults/{id} |
— | Closure |
| GET | /demo/inject/defaults/{id}/{page} |
— | Closure |
| GET | /demo/inject/aliases/{id} |
— | Closure |
| GET | /demo/inject/req-segment/{req} |
— | Closure |
| GET | /demo/route/ns/items |
— | Closure |
| GET | /demo/route/ns-path/{path} |
— | Closure |
| GET | #^/demo/route/pattern$# |
— | Closure |
| GET | /demo/response/json |
— | Closure |
| GET | /demo/response/redirect-301 |
— | Closure |
| GET | /demo/response/redirect-302 |
— | Closure |
| GET | /demo/response/headers |
— | Closure |
| GET | /demo/response/cookie |
— | Closure |
| GET | /demo/coroutine/parallel |
— | Closure |
| GET | /demo/coroutine/channel |
— | Closure |
| GET | /demo/store/set-get |
— | Closure |
| GET | /demo/store/incr |
— | Closure |
| GET | /demo/store-roundtrip |
— | Closure |
| GET | /demo/pubsub/publish |
— | Closure |
| GET | /demo/pubsub/publish-reliable |
— | Closure |
| GET | /demo/pubsub/log |
— | Closure |
| GET | /demo/counter/increment |
— | Closure |
| GET | /demo/session/write |
— | Closure |
| GET | /demo/session/read |
— | Closure |
| GET, POST | /demo/middleware/cors |
— | Closure |
| GET | /demo/middleware/etag |
— | Closure |
| GET | /demo/middleware/compress |
— | Closure |
| GET | /demo/fragments/contacts |
— | Closure |
| GET | /demo/view/inject/url/{id} |
— | Closure |
| GET | /demo/view/inject/request-only |
— | Closure |
| GET | /demo/view/inject/url-response/{id} |
— | Closure |
| GET | /demo/view/inject/all/{id} |
— | Closure |
| GET | /demo/view/inject/defaults/{id} |
— | Closure |
| GET | /demo/view/response/json |
— | Closure |
| GET | /demo/view/response/redirect-302 |
— | Closure |
| GET | /demo/view/response/redirect-301 |
— | Closure |
| GET | /demo/view/response/headers |
— | Closure |
| GET | /demo/view/response/cookie |
— | Closure |
| GET | /demo/view/store/set-get |
— | Closure |
| GET | /demo/view/store/incr |
— | Closure |
| GET | /demo/view/counter/increment |
— | Closure |
| GET | /demo/view/middleware/cors |
— | Closure |
| GET | /demo/view/middleware/etag |
— | Closure |
| GET | /demo/view/middleware/compress |
— | Closure |
| GET | /demo/view/streaming/ssr |
— | Closure |
| GET | /demo/view/streaming/stream |
— | Closure |
| GET | /demo/view/streaming/sse |
— | Closure |
| GET | /demo/view/notes/widget |
— | Closure |
| GET | /demo/view/chat/widget |
— | Closure |
| GET | /demo/view/websocket/counter |
— | Closure |
| GET | /demo/view/tictactoe/play |
— | Closure |
| GET | /demo/view/chatroom/widget |
— | Closure |
| GET | /demo/fatal500 |
— | Closure |
| GET | /demo/rooms/{action} |
— | Closure |
| GET | /docs/guide/{topic} |
— | Closure |
| GET | #^/docs/api(/.*)?$# |
— | Closure |
| GET | /http/redirect/{code} |
— | Closure |
| GET | /http/redirect-target |
— | Closure |
| GET, POST | /http/cors-data |
— | Closure |
| GET | /http/head-test |
— | Closure |
| GET, POST, PUT | /http/options-test |
— | Closure |
| GET | /http/etag-test |
— | Closure |
| GET | /http/compress-test |
— | Closure |
| GET | /http/cookie-test |
— | Closure |
| GET | /http/range-test |
— | Closure |
| GET | /http/sendfile-test |
— | Closure |
| GET | /http/session-redirect |
— | Closure |
| GET | /http/session-target |
— | Closure |
| POST | /api/learn/demo/counter-bump |
— | Closure |
| POST | /api/learn/demo/counter-reset |
— | Closure |
| GET | /api/learn/notes/search |
— | Closure |
| GET | /api/learn/notes/{id} |
— | Closure |
| POST | /api/learn/notes/{id} |
— | Closure |
| DELETE | /api/learn/notes/{id} |
— | Closure |
| POST | /api/learn/demo/check |
— | Closure |
| POST | /api/learn/demo/session-bump |
— | Closure |
| GET | /demo/view/sessions/counter |
— | Closure |
| POST | /api/learn/demo/store-bump |
— | Closure |
| POST | /api/learn/demo/store-reset |
— | Closure |
| POST | /api/learn/demo/store-write |
— | Closure |
| GET | /api/learn/demo/greeting |
— | Closure |
| POST, GET | /api/learn/demo/incr |
— | Closure |
| GET | /api/learn/demo/timing |
— | Closure |
| GET | /api/learn/demo/render |
— | Closure |
| GET | /api/learn/demo/render-to-string |
— | Closure |
| GET | /api/learn/demo/render-stream |
— | Closure |
| GET | /api/learn/chatroom/recent |
— | Closure |
| GET | /api/learn/chatroom/lobby |
— | Closure |
| GET | /metric/get/{id} |
— | Closure |
| GET | /demo/middleware/route-level |
RequestIdMiddleware → HeaderMiddleware | Closure |
| GET | /demo/middleware/plain |
— | Closure |
| GET | /demo/middleware/blocked |
ReturnMiddleware | Closure |
| GET | /demo/mwgroup/alpha |
HeaderMiddleware | Closure |
| GET | /demo/mwgroup/beta |
HeaderMiddleware | Closure |
| GET | /demo/scoped/test |
— | Closure |
| GET | /demo/middleware/visualize |
— | Closure |
| GET | /parity/filter-input |
— | Closure |
| GET | /parity/sapi |
— | Closure |
| GET | /parity/server |
— | Closure |
| GET | /parity/header-callback |
— | Closure |
| GET | /sitemap.xml |
— | Closure |
| GET | /stream/ssr |
— | Closure |
| GET | /stream/words |
— | Closure |
| GET | /stream/events |
— | Closure |
| GET | /stream/echo-yield |
— | Closure |
| GET | /stream/pre-echo |
— | Closure |
| GET | /timers |
— | Closure |
| GET | /timers/counter |
— | Closure |
| GET | /timers/sse |
— | Closure |
| GET | /timers/oneshot |
— | Closure |
| GET | /timers/metrics |
— | Closure |
| GET, POST, PUT, DELETE, PATCH | /api/{module}/{rquest} |
— | Closure |
| GET, POST, PUT, DELETE, PATCH | /api/{rquest} |
— | Closure |
| GET, POST | #^/.*\.php$# |
— | Closure |
| GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD | #(^|/)\.(?!well-known(?:/|$))[^/]*(/|$)# |
— | Closure |
| GET, POST | / |
— | Closure |
| GET, POST | /{cgifile}\.py/? |
— | Closure |
| GET, POST | /{cgidir}/{cgiuri}\.py/? |
— | Closure |
| GET, POST | /{cgifile}\.pl/? |
— | Closure |
| GET, POST | /{cgidir}/{cgiuri}\.pl/? |
— | Closure |
| GET, POST | #^/cgi\-bin/(?P<rest>.+?)/?$# |
— | Closure |
| GET, POST | /{file}/? |
— | Closure |
| GET, POST | /{dir}/{uri}/? |
— | Closure |
Live demos
CORS — Access-Control-Allow-Origin on every response
// Add middleware once in app.php:
$app->addMiddleware(new CorsMiddleware(['*']));
// Hit any endpoint with Origin header:
// curl -H "Origin: http://app.test" https://php.zeal.ninja/demo/middleware/cors
// → Access-Control-Allow-Origin: *
ETag / 304 — conditional GET
// ETagMiddleware auto-generates W/"md5(body)" on GET
// Second request with If-None-Match: <etag> → 304 Not Modified
// First hit:
// curl -D - https://php.zeal.ninja/http/etag-test
// → ETag: W/"abc..."
// Second hit:
// curl -H 'If-None-Match: W/"abc..."' https://php.zeal.ninja/http/etag-test
// → HTTP/1.1 304 Not Modified (empty body)
Compression — gzip when Accept-Encoding: gzip
// OpenSwoole handles runtime compression by default.
// Keep CompressionMiddleware only as a reference if you disable http_compression.
// curl --compressed https://php.zeal.ninja/http/compress-test
// → Content-Encoding: gzip (body is compressed)
Per-middleware reference
CorsMiddleware
CORS preflight (OPTIONS + Origin) plus Access-Control-* headers on every response. There is no Apache/nginx parity here — CORS is a modern browser concern, not a server-config item.
use ZealPHP\Middleware\CorsMiddleware;
$app->addMiddleware(new CorsMiddleware(
origins: ['https://app.example.com', 'https://admin.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
headers: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
));
ETagMiddleware
Generates W/"md5(body)" on GET responses; returns 304 Not Modified when the client's If-None-Match matches. Apache parity: FileETag. nginx parity: etag on; + if_modified_since.
use ZealPHP\Middleware\ETagMiddleware;
$app->addMiddleware(new ETagMiddleware());
CompressionMiddleware
Reference gzip/deflate body compression. OpenSwoole's http_compression is enabled by default — only register this middleware if you've disabled it. Apache parity: mod_deflate. nginx parity: gzip on; + gzip_types.
use ZealPHP\Middleware\CompressionMiddleware;
// Only register if you've turned off OpenSwoole's http_compression.
$app->addMiddleware(new CompressionMiddleware(
minLength: 1024, // do not compress small bodies
level: 6, // 1..9 — same as zlib
skipProxiedRequests: true, // skip when Via header is present
));
RangeMiddleware
RFC 7233 Range requests. Adds Accept-Ranges: bytes, returns 206 Partial Content for single or multi-range requests, 416 for unsatisfiable ranges, and honors If-Range ETag pinning. Required for video seeking and resumable downloads. nginx serves this automatically; ZealPHP needs the middleware registered.
use ZealPHP\Middleware\RangeMiddleware;
$app->addMiddleware(new RangeMiddleware());
// Now: curl -r 0-1023 /video.mp4 → 206 Partial Content (first 1024 bytes)
SessionStartMiddleware
Eagerly starts a session and sends Set-Cookie: PHPSESSID=... for first-time visitors. Without it, CoSessionManager only starts sessions when a PHPSESSID cookie already exists — so first-time visitors see no session cookie and session state resets every request. The secure flag auto-detects HTTPS via X-Forwarded-Proto, HTTPS, or port 443 — works behind Traefik/Nginx and on direct HTTP. Override with ZEALPHP_SESSION_SECURE.
use ZealPHP\Middleware\SessionStartMiddleware;
$app->addMiddleware(new SessionStartMiddleware());
RequestIdMiddleware
Assigns every request a correlation id and echoes it on the response (default header X-Request-Id), so one request can be traced across logs, downstream services, and the client. With trustInbound: true (the default), an id already set by an upstream proxy is propagated; otherwise a fresh 32-hex-char id is minted (bin2hex(random_bytes(16))). The id is also written to the per-request memo, so handlers read it via RequestContext::once('request_id', fn() => null) / RequestContext::has('request_id'). The kind of edge concern you'd usually add at the proxy — expressed as an in-process middleware your handlers can also see. Stateless and coroutine-safe: the id lives in $g (request context), never on the shared middleware instance.
use ZealPHP\Middleware\RequestIdMiddleware;
use ZealPHP\RequestContext;
// Global — every request gets a correlation id
$app->addMiddleware(new RequestIdMiddleware());
// Or per-route via an alias (see "Per-route middleware" below)
App::middlewareAlias('request-id', fn() => new RequestIdMiddleware());
$app->route('/api/job', function () {
$id = RequestContext::once('request_id', fn() => null); // read it in the handler
return ['job' => 'queued', 'request_id' => $id];
}, middleware: ['request-id']);
// Custom header / always mint a fresh id (ignore inbound)
$app->addMiddleware(new RequestIdMiddleware('X-Correlation-Id', trustInbound: false));
IniIsolationMiddleware
Snapshots ini_set() changes (timezone, error_reporting, display_errors, memory_limit, etc.) at request start and restores them on exit. Opt-in defence against ini-value leakage across requests on long-running workers — see what survives a request. Enable with ZEALPHP_INI_ISOLATE=1 or by registering it explicitly. This is a framework PSR-15 middleware for setups without ext-zealphp; coroutine-legacy isolates ini_set() per coroutine natively at the ext level (the S9g isolation stage), so you don't need this middleware there.
use ZealPHP\Middleware\IniIsolationMiddleware;
// Default — snapshots a curated key list
$app->addMiddleware(new IniIsolationMiddleware());
// Or pass an explicit list to track
$app->addMiddleware(new IniIsolationMiddleware([
'date.timezone', 'memory_limit', 'error_reporting',
]));
CharsetMiddleware
Auto-appends ; charset=utf-8 to text-ish response Content-Type values that don't already declare a charset. Reads App::$default_charset (settable via App::defaultCharset('utf-8')). Apache parity: AddDefaultCharset utf-8 + AddCharset utf-8 .css .js .html.
use ZealPHP\Middleware\CharsetMiddleware;
App::defaultCharset('utf-8'); // optional — utf-8 is the default
$app->addMiddleware(new CharsetMiddleware());
// → text/html → text/html; charset=utf-8 (only if not already set)
CacheControlMiddleware
Extension-based Cache-Control: max-age=N, public for static-asset responses. Apache parity: <FilesMatch "\.(css|js|jpg|png)$"> Header set Cache-Control "max-age=2628000". nginx parity: location ~* \.(css|js)$ { expires 30d; } partial.
use ZealPHP\Middleware\CacheControlMiddleware;
$app->addMiddleware(new CacheControlMiddleware([
'css' => ['max-age' => 2628000, 'public' => true], // 1 month
'js' => ['max-age' => 2628000, 'public' => true],
'jpg' => ['max-age' => 31536000, 'public' => true], // 1 year
'png' => ['max-age' => 31536000, 'public' => true],
'woff2' => ['max-age' => 31536000, 'public' => true, 'immutable' => true],
]));
ExpiresMiddleware
Adds the legacy HTTP/1.0 Expires: header by content type. Pairs with CacheControlMiddleware for full Apache mod_expires parity. nginx parity: expires 30d; in a location block.
use ZealPHP\Middleware\ExpiresMiddleware;
$app->addMiddleware(new ExpiresMiddleware([
'image/jpeg' => '+1 year',
'image/png' => '+1 year',
'text/css' => '+1 month',
'application/javascript' => '+1 month',
'__default' => '+1 hour',
]));
HeaderMiddleware
Declarative response-header manipulation: set (overwrite), add (append), unset (remove). Conditional variants run only on specific status codes or content types. Apache parity: Header set / append / unset / add / merge (mod_headers).
use ZealPHP\Middleware\HeaderMiddleware;
$app->addMiddleware(new HeaderMiddleware([
'set' => [
'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload',
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'SAMEORIGIN',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
],
'add' => ['Link' => '</css/zealphp.css>; rel=preload; as=style'],
'unset' => ['X-Powered-By'],
]));
BasicAuthMiddleware
HTTP Basic Auth — htpasswd file or callback verifier. Returns 401 with WWW-Authenticate: Basic on missing/invalid credentials. Apache parity: AuthType Basic + AuthName + AuthUserFile + Require. nginx parity: auth_basic "Realm" + auth_basic_user_file htpasswd.
use ZealPHP\Middleware\BasicAuthMiddleware;
use ZealPHP\Middleware\ScopedMiddleware;
// 1) htpasswd-file backend, scoped to /admin
$app->addMiddleware(ScopedMiddleware::location(
new BasicAuthMiddleware(
htpasswdFile: __DIR__ . '/.htpasswd',
realm: 'Admin Area',
),
'/admin'
));
// 2) callback verifier — lets you check against the DB, scoped to /api/private
$app->addMiddleware(ScopedMiddleware::location(
new BasicAuthMiddleware(
verify: fn($user, $pass) => User::checkCredentials($user, $pass),
realm: 'API',
),
'/api/private'
));
IpAccessMiddleware
CIDR allow/deny lists. Apache parity: legacy Allow from / Deny from / Order Allow,Deny (mod_access_compat) and modern Require ip. Pairs naturally with App::$trusted_proxies + App::clientIp() for correct client-IP resolution behind a front proxy.
use ZealPHP\Middleware\IpAccessMiddleware;
use ZealPHP\Middleware\ScopedMiddleware;
// Allow only internal networks to hit /admin.
// The middleware is deny-first: any IP not in 'allow' is refused.
// No 'deny' entry is needed — the allow list already excludes everything else.
$app->addMiddleware(ScopedMiddleware::location(
new IpAccessMiddleware([
'allow' => ['10.0.0.0/8', '192.168.0.0/16', '127.0.0.1/32'],
]),
'/admin'
));
RateLimitMiddleware
Sliding-window request rate limiter using Store for cross-worker shared state. nginx parity: limit_req zone=one rate=10r/s burst=20;. Returns 429 Too Many Requests with Retry-After when the window is full.
- Automatic IP Resolution: Automatically uses
App::clientIp()to handleX-Forwarded-Forand trusted proxies. - Automatic Loopback Bypass: Requests from
127.0.0.1or::1are automatically bypassed (not rate-limited) so your local testing and integration suites don't get blocked. Opt back in by settingZEALPHP_RATE_LIMIT_LOOPBACK=1. - Automatic Fail-Open: If the
Storetable fills up or doesn't exist, the middleware automatically fails open (allows the request through) and logs a warning instead of taking your application down.
use ZealPHP\Middleware\RateLimitMiddleware;
// Create the backing Store once (BEFORE $app->run())
Store::make('rate_limit', 100_000, [
'count' => [Store::TYPE_INT, 4], // column spec is a positional [type, size] tuple
'reset' => [Store::TYPE_INT, 8],
]);
// 60 requests per minute per client IP (keyed by client IP internally)
$app->addMiddleware(new RateLimitMiddleware(
limit: 60,
window: 60, // seconds
tableName: 'rate_limit',
));
ConcurrencyLimitMiddleware
In-flight concurrent-request cap. nginx parity: limit_conn zone=one 10;. Backed by OpenSwoole\Atomic (Counter) — increments on entry, decrements in a finally. Returns 503 when the cap is reached.
use ZealPHP\Counter;
use ZealPHP\Middleware\ConcurrencyLimitMiddleware;
$counter = new Counter(0, 'inflight'); // (initial, name) — create BEFORE $app->run()
$app->addMiddleware(new ConcurrencyLimitMiddleware(
maxConcurrent: 100, // max concurrent in-flight requests
counter: $counter,
));
BlockPhpExtMiddleware
Refuses any URL ending in .php with a 404. Useful for apps that want extensionless URLs as the only public surface (so scrapers can't enumerate raw files by guessing config.php / admin.php). Apache parity: RewriteCond %{THE_REQUEST} \.php; RewriteRule . - [R=404,L].
use ZealPHP\Middleware\BlockPhpExtMiddleware;
$app->addMiddleware(new BlockPhpExtMiddleware());
// /admin.php → 404
// /config.php?x=1 → 404
// /admin → handled normally (the .php is implicit)
MimeTypeMiddleware
Sets or overrides Content-Type on non-static responses by URL extension or pattern. Static files are MIME-typed by OpenSwoole's static handler — this middleware fills the gap for handler-generated responses. Apache parity: AddType font/woff2 .woff2 and ForceType image/svg+xml.
use ZealPHP\Middleware\MimeTypeMiddleware;
$app->addMiddleware(new MimeTypeMiddleware([
'woff2' => 'font/woff2',
'glb' => 'model/gltf-binary',
'wasm' => 'application/wasm',
]));
BodyRewriteMiddleware
Single-line regex substitution on the response body. Useful for late-stage URL rewriting (e.g., serving a CDN-versioned asset.js?v=abc) or hot-patching templates. Apache parity: Substitute "s/foo/bar/" (mod_substitute). Multi-line and streaming variants are on the roadmap.
use ZealPHP\Middleware\BodyRewriteMiddleware;
$app->addMiddleware(new BodyRewriteMiddleware([
// CDN URL rewrite for HTML responses
'#https?://old-cdn\.example\.com/#' => 'https://cdn.example.com/',
// Asset version cache-bust
'#\.js"#' => '.js?v=' . APP_VERSION . '"',
], contentTypes: ['text/html', 'application/xhtml+xml']));
HostRouterMiddleware
Routes by Host header inside one ZealPHP instance. nginx parity: multiple server { server_name a.com; } blocks. For true isolation prefer one ZealPHP process per host behind Caddy/Traefik; use this when ergonomic co-tenancy is the goal.
use ZealPHP\Middleware\HostRouterMiddleware;
$app->addMiddleware(new HostRouterMiddleware([
'app.example.com' => fn($req, $next) => $next->handle($req),
'admin.example.com' => function ($req, $next) {
// Tighter middleware stack for the admin host
$req = $req->withAttribute('app.scope', 'admin');
return $next->handle($req);
},
'api.example.com' => fn($req, $next) =>
$next->handle($req->withAttribute('app.scope', 'api')),
'__default' => fn($req, $next) => $next->handle($req),
]));
ContentEncodingMiddleware
Sets the response Content-Encoding header from the request URL's dot-separated file suffixes. Apache's find_ct walks every suffix and accumulates an encoding chain — archive.tar.gz with AddEncoding x-gzip .gz yields Content-Encoding: x-gzip, and a doubly-encoded data.gz.gz yields gzip, gzip (order preserved, duplicates intentionally kept). Additive and opt-in: never overrides a Content-Encoding a handler (or a real compression middleware) already set. Apache parity: mod_mime AddEncoding.
use ZealPHP\Middleware\ContentEncodingMiddleware;
$app->addMiddleware(new ContentEncodingMiddleware([
'gz' => 'gzip',
'br' => 'br',
'bz2' => 'bzip2',
]));
ContentLanguageMiddleware
Sets the response Content-Language header from the request URL's dot-separated suffixes — page.en.html with AddLanguage en .en yields Content-Language: en. Multiple language suffixes accumulate in order and are emitted comma-joined (RFC 9110 §8.5 allows a list). Additive and opt-in: only sets the header when the response doesn't already declare one. Apache parity: mod_mime AddLanguage.
use ZealPHP\Middleware\ContentLanguageMiddleware;
$app->addMiddleware(new ContentLanguageMiddleware([
'en' => 'en',
'fr' => 'fr',
'de' => 'de',
]));
MergeSlashesMiddleware
Collapses runs of consecutive slashes in the request path to a single slash before routing, so /a//b///c matches the same route as /a/b/c. Internal rewrite (no redirect) — mutates $g->server['REQUEST_URI'], which the router reads. The query string is left untouched. Register it ahead of route-dependent middleware. Apache parity: MergeSlashes On. nginx parity: merge_slashes on;.
use ZealPHP\Middleware\MergeSlashesMiddleware;
$app->addMiddleware(new MergeSlashesMiddleware());
// Now: /api//users///42 routes the same as /api/users/42
RequestHeaderMiddleware
Manipulates the request headers the application sees, before handlers run. Headers are written into $g->server using the mod_php CGI convention (HTTP_<NAME>, uppercased, dashes → underscores), so apache_request_headers(), getallheaders(), and $g->server['HTTP_*'] reflect the change. Operations: set (replace/create), append / add (comma-joined append or create), unset (remove). Apache parity: mod_headers RequestHeader.
use ZealPHP\Middleware\RequestHeaderMiddleware;
$app->addMiddleware(new RequestHeaderMiddleware([
['op' => 'set', 'name' => 'X-Forwarded-Proto', 'value' => 'https'],
['op' => 'append', 'name' => 'X-Trace', 'value' => 'edge'],
['op' => 'unset', 'name' => 'X-Debug'],
]));
ReturnMiddleware
Unconditionally returns a fixed response — the route handler never runs. For 3xx statuses the second argument is the redirect target (Location); for any other status it's the response body. Pair with ScopedMiddleware to limit it to a path (the nginx location { return ... } shape). nginx parity: return directive.
use ZealPHP\Middleware\ReturnMiddleware;
use ZealPHP\Middleware\ScopedMiddleware;
// Outright block a path
$app->addMiddleware(ScopedMiddleware::location(new ReturnMiddleware(403), '/blocked'));
// Permanent redirect from /old → /new
$app->addMiddleware(ScopedMiddleware::match(new ReturnMiddleware(301, '/new'), '#^/old$#'));
// Health-check stub
$app->addMiddleware(ScopedMiddleware::location(new ReturnMiddleware(200, 'pong'), '/ping'));
ScopedMiddleware
Apply another middleware only to matching request paths — the Apache-container equivalent for middleware. Two factory methods: ScopedMiddleware::location($inner, '/admin') is <Location "/admin"> (literal URL-path prefix — matches /admin, /admin/x, and — like Apache — /administrator; use a trailing slash or a regex for segment precision). ScopedMiddleware::match($inner, '#^/api/#') is <LocationMatch> / <FilesMatch> (PCRE against the path). Outside the scope the inner middleware is skipped entirely.
use ZealPHP\Middleware\ScopedMiddleware;
use ZealPHP\Middleware\BasicAuthMiddleware;
use ZealPHP\Middleware\BlockPhpExtMiddleware;
// Scope BasicAuth to /admin only
$app->addMiddleware(ScopedMiddleware::location(
new BasicAuthMiddleware(htpasswdFile: __DIR__ . '/.htpasswd', realm: 'Admin'),
'/admin'
));
// Refuse *.php URLs anywhere on the host
$app->addMiddleware(ScopedMiddleware::match(new BlockPhpExtMiddleware(), '#\.php$#'));
SetEnvIfMiddleware
Sets request "environment" variables (into $g->server, where mod_php code reads them as $_SERVER) when an attribute of the request matches a regex. The classic use is tagging bots, internal IPs, or URL areas so downstream middleware / handlers can branch on a simple flag. Attribute names mirror Apache: the special tokens Remote_Addr, Remote_Host, Server_Addr, Request_Method, Request_Protocol, Request_URI; any other name is treated as a request header (so User-Agent gives BrowserMatch behaviour). Apache parity: mod_setenvif.
use ZealPHP\Middleware\SetEnvIfMiddleware;
$app->addMiddleware(new SetEnvIfMiddleware([
['attr' => 'User-Agent', 'regex' => '#bot#i', 'set' => ['IS_BOT' => '1']],
['attr' => 'Request_URI', 'regex' => '#^/admin#', 'set' => ['ADMIN_AREA' => '1']],
['attr' => 'Remote_Addr', 'regex' => '#^10\.#', 'set' => ['INTERNAL' => '1']],
]));
BodySizeLimitMiddleware
Rejects oversized request bodies with 413 Content Too Large before the handler runs. Accepts an integer (bytes) or a shorthand string ('10M', '512K'). Pass 0 for unlimited. nginx parity: client_max_body_size 10m;. Apache parity: LimitRequestBody.
use ZealPHP\Middleware\BodySizeLimitMiddleware;
// Reject uploads larger than 10 MB
$app->addMiddleware(new BodySizeLimitMiddleware('10M'));
RedirectMiddleware
Declarative URL redirects — first matching rule short-circuits. Each rule is an associative array with either a 'from' key (prefix match, like Apache Redirect /old /new) or a 'match' key (PCRE regex with capture groups, like RedirectMatch). The per-rule default status is 302; pass an explicit 'status' per rule to override. A rule without a 'to' key is silently skipped. Apache parity: mod_alias (Redirect / RedirectMatch).
use ZealPHP\Middleware\RedirectMiddleware;
$app->addMiddleware(new RedirectMiddleware([
// Permanent prefix redirect
['from' => '/blog', 'to' => '/articles', 'status' => 301],
// Regex redirect with back-reference (permanent)
['match' => '#^/old/(.+)#', 'to' => '/new/$1', 'status' => 301],
// Temporary redirect (302 is the per-rule default)
['from' => '/beta', 'to' => 'https://beta.example.com', 'status' => 302],
]));
RefererMiddleware
Hotlink protection — refuses requests whose Referer header is not in the allowed set with 403 Forbidden. Specs can be plain host names, wildcards (*.example.com), or regexes (prefixed with ~). A missing Referer is allowed by default (allowNone: true). nginx parity: valid_referers / if ($invalid_referer) { return 403; }.
use ZealPHP\Middleware\RefererMiddleware;
$app->addMiddleware(new RefererMiddleware(
referers: ['example.com', '*.example.com'],
serverNames: ['example.com'], // own hosts always allowed
));
CsrfMiddleware
Double-submit CSRF protection for state-mutating requests (POST, PUT, PATCH, DELETE). Generates a per-session token and validates it on every non-safe request. Pass an array of URL paths to exempt (e.g. API endpoints using their own token scheme).
use ZealPHP\Middleware\CsrfMiddleware;
$app->addMiddleware(new CsrfMiddleware(
exempt: ['/api/', '/webhooks/'],
));
HealthCheckMiddleware
Short-circuits on health-check paths and returns a JSON response — 200 {"status":"ok"} when healthy, 503 {"status":"unhealthy",...} when the optional check callback returns an error string. Default path is /healthz; pass additional paths (/readyz, /_health) as needed. Route handlers never run for health-check paths.
use ZealPHP\Middleware\HealthCheckMiddleware;
use ZealPHP\Store;
$app->addMiddleware(new HealthCheckMiddleware(
paths: ['/healthz', '/readyz'],
check: function (): ?string {
// Return null → healthy; non-null string → unhealthy reason
try {
Store::get('rate_limit', '__ping');
return null;
} catch (\Throwable $e) {
return 'store unreachable: ' . $e->getMessage();
}
},
));
Custom middleware
Middleware always returns a Psr\Http\Message\ResponseInterface — that's PSR-15's contract, not ZealPHP's. Inside the route handler that the middleware wraps, the handler still uses the universal return contract; ZealPHP's ResponseMiddleware converts the return into a PSR-7 response before your middleware sees it.
use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};
class TimingMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$start = microtime(true);
$response = $handler->handle($request); // call inner stack
$elapsed = round((microtime(true) - $start) * 1000, 2);
response_add_header('X-Response-Time', "$elapsed ms");
return $response;
}
}
// Register:
$app->addMiddleware(new TimingMiddleware());