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

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

ClassApache / nginx parityWhat it does
CorsMiddlewaren/a (modern browser feature)CORS preflight + Access-Control headers on every response
ETagMiddlewareApache FileETag, nginx etag onGenerates W/"md5" ETag, returns 304 on cache hit
CompressionMiddlewareApache mod_deflate, nginx gzip onReference gzip/deflate; runtime compression is handled by OpenSwoole by default
RangeMiddlewareHTTP/1.1 RFC 7233 (universally expected)Accept-Ranges: bytes; 206 for single/multi-range; 416 for unsatisfiable
SessionStartMiddlewaren/a (PHP-native sessions)Eagerly starts a session and sends Set-Cookie for new visitors
RequestIdMiddlewaren/a (request correlation)Assigns/propagates X-Request-Id; stores it in the per-request memo so handlers can read it
IniIsolationMiddlewaren/a (long-running runtime concern)Snapshots and restores ini_set() changes per request
CharsetMiddlewareApache AddDefaultCharset / AddCharsetAppends ; charset=utf-8 to text-ish response Content-Type
CacheControlMiddlewareApache <FilesMatch> Header set Cache-ControlExtension-keyed Cache-Control: max-age=N, public for static assets
ExpiresMiddlewareApache mod_expires, nginx expires 30dAdds Expires: header by content type
HeaderMiddlewareApache mod_headers (Header set/add/unset)Declarative response-header manipulation with conditional variants
BasicAuthMiddlewareApache AuthType Basic, nginx auth_basicHTTP Basic Auth: htpasswd file or callback verifier
IpAccessMiddlewareApache Allow from / Deny fromCIDR allow/deny lists with allow-first or deny-first ordering
RateLimitMiddlewarenginx limit_reqSliding-window request rate limiter backed by Store (cross-worker)
ConcurrencyLimitMiddlewarenginx limit_connIn-flight concurrent-request cap backed by Counter
BlockPhpExtMiddlewareApache RewriteRule ^(.+)\.php$ - [F]Refuses *.php URLs with 404 (for extensionless-only public surfaces)
MimeTypeMiddlewareApache AddType / ForceTypeSets/overrides Content-Type on non-static responses by extension or pattern
BodyRewriteMiddlewareApache mod_substitute (Substitute s/x/y/)Single-line regex substitution on response body
HostRouterMiddlewarenginx server_name a.com b.comDispatches per-host routes inside one ZealPHP instance
ContentEncodingMiddlewareApache mod_mime AddEncodingSets Content-Encoding from URL file suffixes (e.g. .gz, .br)
ContentLanguageMiddlewareApache mod_mime AddLanguageSets Content-Language from URL file suffixes (e.g. .en, .fr)
MergeSlashesMiddlewareApache MergeSlashes On, nginx merge_slashesCollapses runs of consecutive slashes in the request path before routing
RequestHeaderMiddlewareApache mod_headers RequestHeaderset / append / unset on inbound request headers before handlers run
ReturnMiddlewarenginx return directiveUnconditionally returns a fixed response — pair with ScopedMiddleware
ScopedMiddlewareApache <Location> / <LocationMatch> containersApply another middleware only to matching request paths
SetEnvIfMiddlewareApache mod_setenvif / BrowserMatchSet request "env" vars in $g->server when a request attribute matches a regex
BodySizeLimitMiddlewarenginx client_max_body_size, Apache LimitRequestBodyRejects oversized request bodies with 413 Content Too Large
RedirectMiddlewareApache mod_alias (Redirect / RedirectMatch)Declarative URL redirects — prefix and regex rules, first match short-circuits
RefererMiddlewarenginx valid_referers / $invalid_refererHotlink protection — refuses requests whose Referer is not in the allowed set
CsrfMiddlewaren/a (framework-level)Double-submit CSRF protection for state-mutating requests
HealthCheckMiddlewaren/a (ops concern)Short-circuits on health-check paths (default /healthz); returns 200/503 JSON
LocationHeaderMiddlewaren/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.php — middleware registration order
$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).

Per-route middleware — instances and/or alias strings
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.

app.php — register aliases before $app->run()
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) => ...).

Nested route groups
$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.php — scope a chain to a URL path (routes AND api, one registry)
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.

api/admin/users/delete.php — per-file guard, co-located with 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.

globalApp::whengroup / routeapi in-filehandler

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:

StatusMiddleware / pattern
Coroutine-safe nowRateLimitMiddleware + ConcurrencyLimitMiddleware (backed by Store / Counter shared memory)
Feasible nowForwardAuth, request-level CircuitBreaker, Retry — on hooked backends (the ZealPHP\HTTP coroutine client, Store, the pooled Redis client)
BlockedDB-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.

describeRoutes() — the visualizer feed
$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

Request CorsMiddlewareETagMiddlewareRangeMiddlewareSessionStartMiddleware ResponseMiddleware (router) handler Response

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

GET /demo/middleware/route-level RequestIdMiddlewareHeaderMiddleware Closure
GET /demo/middleware/blocked ReturnMiddleware Closure
GET /demo/mwgroup/alpha HeaderMiddleware Closure
GET /demo/mwgroup/beta HeaderMiddleware Closure

Aliases

request-iddemo-headergroup-headerblockapi-secured

All routes 206 total

MethodsPathRoute middlewareHandler
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

GET 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: *
LIVE OUTPUT Click Run →
GET 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)
LIVE OUTPUT Click Run →
GET 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)
LIVE OUTPUT Click Run →

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',
]));

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 handle X-Forwarded-For and trusted proxies.
  • Automatic Loopback Bypass: Requests from 127.0.0.1 or ::1 are automatically bypassed (not rate-limited) so your local testing and integration suites don't get blocked. Opt back in by setting ZEALPHP_RATE_LIMIT_LOOPBACK=1.
  • Automatic Fail-Open: If the Store table 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());