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.

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:

HelperWhat it doesBest 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', ...) in route/health.php, it wins over the implicit ZealAPI dispatcher because route/*.php files load before the implicit catch-all. Useful for overriding one API endpoint without touching api/.
  • 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/new never 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/about and /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

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.
  • nsRoute captures single segments; nsPathRoute swallows 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.