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.

Routing & Parameter Injection

ZealPHP uses reflection to inject route parameters, $request, $response, and $app into handlers by name — no annotations, no containers. $req / $res are accepted as short aliases for $request / $response.

File-based routing — just like LAMP

Drop a .php file in public/. It becomes a route. No config, no registration, no framework code needed.

FileURLNotes
public/index.php/Root route
public/about.php/aboutFilename becomes the path (no .php)
public/users/list.php/users/listSubdirectories work
public/admin/index.php/adminDirectory index

Inside these files, everything you already know works:

public/dashboard.php — coroutine-safe form (works in both modes)
<?php
use ZealPHP\RequestContext;

$g = RequestContext::instance();
session_start();
if (empty($g->session['user'])) { header('Location: /login'); exit; }
?>
<h1>Welcome, <?= htmlspecialchars($g->session['user']['name']) ?></h1>
<p>Your orders: <?= count($g->get['filter'] ?? []) ?> filters active</p>
This is the migration on-ramp. Drop your existing PHP files into public/ and they run on OpenSwoole immediately — session_start(), header(), echo all work unchanged via ext-zealphp overrides. The recommended form is $g->session / $g->get via RequestContext::instance() — works in both modes, no extension needed. With ext-zealphp, $_GET / $_SESSION are also per-coroutine safe in both modes (saved/restored on every yield/resume). See the $g vs $_* parity rule and the migration ladder.

Same convention works for APIs — drop files in api/:

FileURLNotes
api/device/list.php/api/device/listFilename match — $list handles all methods
api/device/add.php/api/device/addFilename match — $add handles all methods
api/users.php/api/usersPer-method — $get/$post/… handle their method; others get 405

Two conventions. Filename match: the closure variable matches the filename — all HTTP methods reach it. Per-method: define $get, $post, $put, $delete, $patch — each handles its method, undefined ones return 405. See /api#per-method-dispatch.

api/device/list.php — filename match (all methods)
<?php
$list = function () {
    $this->response($this->json(['devices' => []]), 200);
};

Public files ride the universal return contract — same shapes as a route handler.

Programmatic routes

When you need URL parameters, WebSocket, or middleware — use programmatic routes. File-based routing handles the rest.

Route types

MethodExampleUse when
route()/users/{id}Standard URL with named segments
nsRoute()/admin/usersGroup routes under a namespace prefix
nsPathRoute()/api/v1/users/listNamespace + catch-all last segment (includes slashes)
patternRoute()/raw/(?P<rest>.*)Full regex control
ws()/ws/chatWebSocket endpoint

Route options — methods & raw

All four registrars (route, nsRoute, nsPathRoute, patternRoute) take the same options — pass them as an array (2nd argument) or as named arguments. The two forms are interchangeable and compose; a named argument overrides the matching array key.

OptionType / defaultWhat it does
methodsarray, default ['GET']Allowed HTTP verbs. Lowercase is normalised to uppercase; a request with an unlisted verb is rejected.
rawbool, default falseSkip the per-request output buffer (ob_start()). For handlers that stream or write to $response directly (SSE, $response->stream(), binary payloads) instead of letting the framework capture echoed output.
Array form and named-argument form are equivalent
// Two-arg shorthand — GET only:
$app->route('/hello/{name}', fn($name) => "Hi {$name}");

// Array options (backward-compatible):
$app->route('/users', ['methods' => ['GET', 'POST']], $handler);

// Named arguments — same result:
$app->route('/users', $handler, methods: ['GET', 'POST']);

// raw: skip output buffering for a hand-rolled streaming writer:
$app->route('/export.csv', function($response) {
    $response->stream(fn($write) => $write("id,name\n"));
}, methods: ['GET'], raw: true);

Per-route middleware

Attach a PSR-15 middleware chain to a single route — auth, headers, rate-limit, a redirect — without registering it globally. The middleware option is accepted by all four registrars (route, nsRoute, nsPathRoute, patternRoute), and like methods/raw it works as a named argument and as an array-option key. It's purely additive and backward-compatible — routes without middleware take the unchanged fast path with zero added work.

OptionType / defaultWhat it does
middlewarearray, default []A list of MiddlewareInterface instances and/or named alias strings. Each runs only for this route, wrapping the handler.

Entries are either a ready middleware instance or an alias string registered with App::middlewareAlias(). The two declaration forms — the middleware: named argument and the ['middleware' => [...]] array-option key — combine: array-option entries run first (outermost), then named-argument entries.

Per-route middleware — instances and alias strings, on any registrar
use ZealPHP\Middleware\{RequestIdMiddleware, IpAccessMiddleware};

// Mix alias strings with a live instance:
$app->route('/admin/users', fn() => User::all(), methods: ['GET'],
    middleware: ['auth', 'request-id', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']])]);

// Same option on nsRoute / nsPathRoute / patternRoute:
$app->nsRoute('api', '/jobs', $list, middleware: ['request-id']);

// Array-option form — entries here run OUTSIDE the named-arg ones:
$app->route('/report', ['middleware' => ['auth']], $handler, middleware: ['request-id']);
// chain: auth (outer) -> request-id -> handler

Named middleware aliases

Register a reusable middleware once, reference it by name everywhere — the named-and-shared vocabulary from Traefik, the route-alias pattern from Laravel. Pass either a ready MiddlewareInterface instance (reused as-is) or a factory callable that returns one.

App::middlewareAlias() — instance, factory, and parameterised form
use ZealPHP\Middleware\{BasicAuthMiddleware, IpAccessMiddleware, RateLimitMiddleware, RequestIdMiddleware};

App::middlewareAlias('auth',       fn() => new BasicAuthMiddleware($verifier));
App::middlewareAlias('admin-only', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']]));
App::middlewareAlias('request-id', fn() => new RequestIdMiddleware());

// Parameterised reference: 'throttle:120' calls the factory with the
// comma-split args, e.g. fn('120') — mirrors Laravel 'throttle:60,1'.
App::middlewareAlias('throttle', fn($n = '60') => new RateLimitMiddleware(limit: (int)$n));

$app->route('/admin/users', $fn, middleware: ['auth', 'admin-only', 'throttle:120']);
Factories run once, instances are shared. An alias factory is invoked once at App::run() (boot, single-coroutine), and the resulting instance is shared across every request that uses the alias. So middleware must be stateless — one object serves all concurrent coroutines; keep per-request state in $g (RequestContext), never on the middleware object.

Route groups

Apply a shared URL prefix and/or a shared middleware chain to many routes at once. $app->group() hands your callback a RouteGroup whose route()/nsRoute()/nsPathRoute()/patternRoute()/group() mirror App's — each prepends the group prefix and prepends the group's shared middleware.

$app->group() — shared prefix + middleware, nesting
$app->group('/admin', ['auth', 'admin-only'], function ($g) {
    $g->route('/users',    fn() => User::all());       // /admin/users
    $g->route('/settings', fn() => Settings::get());   // /admin/settings

    $g->group('/audit', ['audit-log'], function ($g) { // nests the prefix + middleware
        $g->route('/recent', fn() => Audit::recent()); // /admin/audit/recent
        // chain: auth -> admin-only -> audit-log -> handler
    });
});

// Middleware is optional — pass just a prefix and a registrar:
$app->group('/v1', function ($g) {
    $g->route('/ping', fn() => 'pong');                // /v1/ping
});
Group middleware wraps outside the route's own. Groups nest — an inner $g->group() composes its prefix and middleware onto the outer group's. One exception: patternRoute() inside a group does not auto-apply the prefix (a raw regex is ambiguous to prefix, so bake it into the pattern yourself) — the group middleware still applies.

Execution order

A request walks the chain from the outside in; the response unwinds in reverse. A middleware that returns without calling the handler (a 403, a redirect) short-circuits — the handler and everything inside it never run.

OrderLayerRule
1 (outermost)Global middlewareFirst-registered ($app->addMiddleware()) is outermost. Wraps every route.
2Group middlewareThe group's shared chain, outer groups before inner.
3Route middlewareThis route's own list — first-listed is outermost. (Array-option entries precede named-arg entries.)
4 (innermost)HandlerRuns last; its return value rides the universal return contract.
Visualise it. $app->describeRoutes() returns the global chain (ending in ResponseMiddleware (router)), the registered aliases, and every route with its resolved middleware chain — before or after run(). The middleware visualizer renders it as a Traefik-style chain view; GET /demo/middleware/visualize returns the raw JSON.

Worked example

A correlation id on every request, basic-auth + an IP allow-list on the admin area, and a per-route rate limit — composed from aliases, a group, and an inline instance.

app.php — aliases + group + per-route middleware
use ZealPHP\App;
use ZealPHP\Middleware\{BasicAuthMiddleware, IpAccessMiddleware, RateLimitMiddleware, RequestIdMiddleware};

$app = App::instance();

// 1) Register reusable middleware by name.
App::middlewareAlias('request-id', fn() => new RequestIdMiddleware());
App::middlewareAlias('auth',       fn() => new BasicAuthMiddleware($verifier));
App::middlewareAlias('throttle',   fn($n = '60') => new RateLimitMiddleware(limit: (int)$n));

// 2) request-id on every request, globally (outermost of all).
$app->addMiddleware(new RequestIdMiddleware());

// 3) The whole /admin area is auth-gated and IP-restricted.
$app->group('/admin', ['auth', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']])], function ($g) {
    $g->route('/users', fn() => User::all());                       // auth -> ip -> handler

    // 4) One route gets an extra, tighter rate limit on top of the group chain.
    $g->route('/export', fn() => Report::export(), methods: ['POST'], middleware: ['throttle:30']);                         // auth -> ip -> throttle:30 -> handler
});

$app->run();
Try the live demos. GET /demo/middleware/route-level stamps X-Request-Id + X-Demo-Route and echoes the request id; /demo/middleware/plain has no middleware (proving per-route scoping); /demo/middleware/blocked short-circuits with a 403 before the handler runs; /demo/mwgroup/alpha and /demo/mwgroup/beta share a group header.

Parameter injection — every case

All panels below auto-run against the live server. The handler signature determines what gets injected. $req / $res are accepted as short aliases for $request / $response — and the reserved framework-object names bind the injected object before any same-named URL segment (security fix #240), so function($req) always receives the wrapper, never a path string.

GET URL param only
$app->route('/users/{id}', function($id) {
    return ['id' => $id];
});
LIVE OUTPUT Click Run →
GET URL param + $request
$app->route('/users/{id}', function($id, $request) {
    return ['id' => $id, 'method' => $request->server['request_method']];
});
LIVE OUTPUT Click Run →
GET URL param + $response
$app->route('/users/{id}', function($id, $response) {
    $response->header('X-User-Id', $id);
    return ['id' => $id, 'response_class' => get_class($response)];
});
LIVE OUTPUT Click Run →
GET $request only
$app->route('/info', function($request) {
    return ['method' => $request->server['request_method'],
            'uri'    => $request->server['request_uri']];
});
LIVE OUTPUT Click Run →
GET All: $id + $request + $response
$app->route('/full/{id}', function($id, $request, $response) {
    $response->header('X-Injected', 'yes');
    return ['id' => $id, 'method' => $request->server['request_method'],
            'response_class' => get_class($response)];
});
LIVE OUTPUT Click Run →
GET Default param value
// ZealPHP has no optional-segment syntax like {page?}.
// Express "optional" params by registering a base route with a default:
$app->route('/paged/{id}', function($id, $page = 1) {
    return ['id' => $id, 'page' => $page];  // page defaults to 1
});
LIVE OUTPUT Click Run →
GET Default overridden by URL
// Same handler — page is 5 from URL
$app->route('/paged/{id}/{page}', function($id, $page = 1) {
    return ['id' => $id, 'page' => $page];
});
LIVE OUTPUT Click Run →

Live route type demos

GET nsRoute — /demo/route/ns/items
$app->nsRoute('demo', '/route/ns/items', function() {
    return ['route_type' => 'nsRoute', 'prefix' => 'demo'];
});
LIVE OUTPUT Click Run →
GET nsPathRoute — catches full path after prefix
$app->nsPathRoute('demo/route/ns-path', '{path}', function($path) {
    return ['route_type' => 'nsPathRoute', 'captured' => $path];
});
LIVE OUTPUT Click Run →
GET patternRoute — regex match
$app->patternRoute('/demo/route/pattern', function() {
    return ['route_type' => 'patternRoute'];
});
LIVE OUTPUT Click Run →

Route priority

Routes are matched in this order — the first match wins. Earlier in the list = higher priority:

#SourceLoaded
1Explicit $app->route() in app.phpBefore $app->run() (already in the table when run() starts)
2Files in route/*.phpAt server startup (auto-included by run() via glob, after app.php routes)
3Implicit API: /api/{module}/{request}Inside $app->run()
4Implicit public files: /, /{file}, /{dir}/{uri}Inside $app->run()
5Fallback handler (if setFallback() registered)When nothing else matches
Override implicit routes by placing a file in route/. For example, to customize /admin/users instead of letting it auto-resolve to public/admin/users.php, define an explicit route in route/admin.php — it loads first and takes precedence.

Apache parity in public/ routing

The implicit public/ routes mirror Apache+mod_php's default DocumentRoot behavior — including the subtle directives most developers don't think about until something breaks. Each is on by default and toggleable via a static flag on App:

Apache directiveZealPHP behaviorFlag
DocumentRoot /path The folder every implicit route and the static handler resolve against — defaults to public/ App::documentRoot('public') (set before App::init())
DirectorySlash On /docs301 /docs/ when docs is a directory under public/ App::$directory_slash = true
DirectoryIndex index.php index.html index.htm Walks the list in order; HTML/HTM served via $response->sendFile() so Range and ETag still work App::$directory_index (array)
AcceptPathInfo On /api.php/users/42SCRIPT_NAME=/api.php, PATH_INFO=/users/42; rewrites REQUEST_URI to just the script App::$path_info = true
<FilesMatch "^\.>" deny Any URL with a dotfile component (.env, .git/config) returns 403. .well-known/ is allow-listed per RFC 8615. App::$block_dotfiles = true
URL traversal rejection %2e%2e, \0, backslash decoded and matched BEFORE route lookup → 400 always on
Static-handler URL whitelist At boot, App::$static_handler_locations defaults to [] (empty). When empty, the framework substitutes a safe whitelist: /css/ /js/ /img/ /images/ /fonts/ /assets/ /static/ /favicon.ico /robots.txt. Anything outside falls through to PHP routing. App::$static_handler_locations (set before run(); [] = use default whitelist)
ErrorDocument N /path App::instance()->setErrorHandler(404, $cb) registers a per-status custom page; catch-all variant: setErrorHandler($cb). See Responses. App::$error_handlers (private)
FileETag / If-None-Match / If-Modified-Since $response->sendFile() emits weak ETag (W/"mtime-size") and Last-Modified; matches return 304. Range request honored on the same path. always on for sendFile()
For ETag on static assets too, disable OpenSwoole's built-in static handler (enable_static_handler => false in $app->run() settings) and add a wildcard route that calls $response->sendFile(). The built-in handler emits Last-Modified only — no ETag, no Range. The trade-off is a small per-request PHP hop. See the Apache parity section.

Pattern routes with named regex groups

patternRoute accepts any regex with named capture groups (PCRE (?P<name>...) syntax). Captured names are injected as handler parameters:

Named capture group → handler parameter
// Match any URL starting with /raw/
$app->patternRoute('/raw/(?P<rest>.*)', ['methods' => ['GET']], function($rest) {
    echo "You requested: $rest";
    return 202;
});

// Multiple groups
$app->patternRoute('/blog/(?P<year>\d{4})/(?P<slug>[a-z-]+)', function($year, $slug) {
    return ['year' => $year, 'slug' => $slug];
});

// Block .php extension entirely
$app->patternRoute('/.*\.php', ['methods' => ['GET', 'POST']], function($response) {
    $response->status(403);
    $response->write("403 Forbidden");
});