Routes & APIs: Advanced Patterns
Regex, namespace groups, fallbacks, and the route-ordering edge cases. Foundations covers the basics.
You will learn
- Regex routes for patterns the filesystem can't express
- nsRoute vs nsPathRoute — when each one is the right fit
- App::setFallback() for hosting unmodified legacy apps under ZealPHP
- The .php-extension toggle and when to flip it
- Route-ordering edge cases — what wins when two routes could match
Regex routes — patternRoute()
When you need to capture URL segments by regex (not by simple
{name} placeholders), reach for patternRoute(). The whole path
after the namespace prefix is matched against a regex; captures land in your handler by name (use (?P<name>…) groups).
// Match /blog/2026/05 — year + month with strict format
// Captures MUST be named (?P<name>...) — the injector matches by name, not position.
$app->patternRoute('/blog/(?P<year>\d{4})/(?P<month>\d{2})', ['methods' => ['GET']],
function ($year, $month) {
return Post::byMonth((int)$year, (int)$month);
}
);
// Match /file/anything-with-slashes/here.txt
$app->patternRoute('/file/(?P<path>.+\.(?:txt|md|json))', ['methods' => ['GET']],
function ($path, $response) {
return $response->sendFile(__DIR__ . '/storage/' . $path);
}
);
Use it when {name} can’t express the constraint (digits-only, fixed length,
file extensions, etc.). Captures must use PCRE named-capture syntax
(?P<name>...) — the framework injects by name, so positional captures are
silently dropped. Otherwise prefer $app->route() with named placeholders —
cleaner signatures and the framework injects by name.
Namespace routes — nsRoute vs nsPathRoute
Namespaces let you group routes under a URL prefix without repeating it in every handler:
| Helper | What it does | Best for |
|---|---|---|
nsRoute('admin', '/users', ...) |
Matches exactly /admin/users. {param} captures a single segment (no slashes). |
Conventional REST under a prefix: /admin/users, /admin/orders |
nsPathRoute('admin', '/users/{path}', ...) |
The final {path} swallows the rest of the URL — slashes and all. |
Catch-all under a prefix: forwarding a sub-app, a legacy router, a CMS |
// nsRoute — strict, single-segment captures
$app->nsRoute('admin', '/users/{id}', fn($id) => Admin::userById($id));
// Matches /admin/users/42, /admin/users/abc.
// Does NOT match /admin/users/42/orders — {id} can't contain a slash.
// nsPathRoute — last param catches everything
$app->nsPathRoute('admin', '{path}', function ($path) {
return Admin::handleAnything($path);
});
// Matches /admin/anything, /admin/users/42/orders, /admin/very/deep/path.
// $path is "anything" or "users/42/orders" or "very/deep/path".
Under the hood, the implicit /api/* handler uses nsPathRoute('api',
'{module}/{rquest}', ...) — that’s how api/device/list.php gets dispatched
from /api/device/list.
The fallback — App::setFallback()
Unmatched URLs return 404 by default. Register a fallback to do something else first — the classic use case is hosting an unmodified legacy app (WordPress, Drupal, anything that does its own routing) behind a ZealPHP server that owns most URLs.
// app.php — before $app->run()
App::setFallback(function () {
// Hand any URL we don't explicitly own to WordPress's front controller.
// App::include() resolves paths relative to public/ (Apache DocumentRoot convention).
App::include('/wordpress/index.php');
});
The fallback is the Apache RewriteRule . /index.php [L] equivalent. You can put
framework routes (your modern endpoints) in route/ and api/, and let
the fallback hand everything else off to the legacy app. This is the pattern the WordPress
showcase uses — github.com/sibidharan/zealphp-wordpress.
The .php extension toggle
By default, ZealPHP strips .php from URLs: public/about.php serves at
/about, and a direct request to /about.php returns 403. You can flip
this when you need raw PHP URLs (some legacy apps generate links with .php in them
and you don’t want to rewrite them):
// app.php — flip BEFORE App::init() / before $app->run()
App::$ignore_php_ext = false; // accept /about.php as a valid URL
// Defaults to true (strip the extension). Set to false to keep .php in URLs.
Most new apps leave it at the default. Flip when you’re porting an app whose own HTML
hard-codes .php links.
Route ordering edge cases
Foundations → routes showed the priority chain. Here’s what bites in practice:
- Two explicit routes for the same URL — first one registered wins. The
second silently never runs. Add a temporary
elog()in your handler if a route seems "ignored." - A pattern that overlaps with
/api/*— if you register$app->route('/api/health', ...)inroute/health.php, it wins over the implicit ZealAPI dispatcher becauseroute/*.phpfiles load before the implicit catch-all. Useful for overriding one API endpoint without touchingapi/. - A
{name}param that captures a literal you wanted — if you register both/users/{id}and/users/new, the literal always wins regardless of registration order. ZealPHP stores exact/literal paths in a separate map and checks it before the pattern loop, so/users/newnever gets captured as$id. The “first-registered-wins” rule applies only when both routes are patterns (e.g. two different{param}or regex routes that both match). - Trailing slashes —
/aboutand/about/are treated as different URLs unless your route pattern includes/?. The implicit public-file route does include this; custom routes typically don’t.
Try it live
- Regex/pattern capture in action — see how URL segments land in handler args
- Implicit /api/* dispatch — one route serving many
api/*.phpfiles
You register $app->nsRoute('admin', '/users/{id}', ...). Does it match /admin/users/42/posts?
Key Takeaways
- Use
patternRoute()when{name}placeholders can't express the constraint. nsRoutecaptures single segments;nsPathRouteswallows slashes — pick by what your URL shape needs.App::setFallback()is the equivalent of Apache's catch-all rewrite — great for hosting legacy apps.- Literal/exact paths always win over
{param}patterns regardless of registration order — first-registered-wins applies only between two pattern routes that both match. - Foundations: How Routes Work covers
public/,api/, and the basic priority chain.