How Routes Work
From URL to handler. The four ways ZealPHP finds your code, the priority order between them, and why api/ exists.
You will learn
- How starting a server makes every file in public/ become a URL automatically
- When to use route/ vs api/ vs public/ — three different routing styles
- The execution priority — which route wins when two could match
- Why ZealPHP needs ApIs as a separate convention, and how auto-delivery works
- How to add a fallback for unmatched URLs
Routes are just .htaccess in disguise
On Apache, .htaccess tells the server "for this URL pattern, run that file." On
nginx, location blocks do the same job. ZealPHP throws both of them out and lets
the filesystem do the routing — but the mental model is identical.
A request walks in. The framework asks: do I have a route for this URL? It checks four sources, in a defined order. The first match wins. The handler runs. Response goes out.
Step 0 — start the server
Before any routing happens, you start the server. Three lines is enough:
<?php
require_once __DIR__ . '/vendor/autoload.php';
ZealPHP\App::init('0.0.0.0', 8080)->run();
Run php app.php. The server binds port 8080, forks workers (one per CPU core),
and waits for requests. From this moment, every .php file in public/
is already a URL. No restart, no config — drop in a file, hit refresh.
The four routing styles
1. public/ — filesystem routing (Apache parallel)
Drop public/about.php, get GET /about. Drop public/blog/post.php,
get GET /blog/post. The URL mirrors the file path; .php is stripped.
Same convention WordPress, Drupal, and any Apache+PHP app already use.
public/index.php → GET /
public/about.php → GET /about
public/blog/post.php → GET /blog/post
public/css/site.css → GET /css/site.css (static files served as-is)
Use it for: marketing pages, docs, anything that’s "show this HTML for that URL."
2. api/ — JSON endpoints with parameter injection
The same filesystem idea, but each file defines a closure (named to match the file) that the framework invokes with named-parameter injection. The filename becomes the last URL segment — it’s NOT the HTTP method:
api/users/list.php → /api/users/list
api/users/create.php → /api/users/create
api/devices/new/id.php → /api/devices/new/id
api/learn/login.php → /api/learn/login
Inside each file you define one closure whose variable name matches basename($file, '.php'):
// api/users/list.php
$list = function ($app, $request, $response) {
// $app is the ZealAPI instance; $this also works (Closure::bind)
return User::all(); // array → auto-JSON encoded
};
The implicit /api/* dispatcher (registered in src/App.php) listens for
all four HTTP verbs — GET, POST, PUT, DELETE — on the same URL, then loads
the matching file and invokes the closure. If your handler cares about the method, read it
yourself:
// api/users/list.php — handle GET + POST in one file
$list = function ($request) {
$method = $request->server['REQUEST_METHOD'];
return match ($method) {
'GET' => User::all(),
'POST' => User::create($request->post),
default => 405, // Method Not Allowed
};
};
Why a separate convention from public/? Because api/ files get
parameter injection ($app, $request, $response,
$server), a ZealAPI base class with helpers like
$this->paramsExists(), and automatic JSON encoding for array/object returns.
public/ files are plain PHP scripts that echo their own output. Pick
api/ when you want "structured JSON endpoint with helpers";
public/ when you want "render HTML for this URL."
3. route/ — explicit routes with URL parameters
Sometimes you need a URL the filesystem can’t express:
/users/{id}, /admin/anything-here, a WebSocket endpoint, a route
where the path captures regex. That’s what route/ is for. Files in this
directory are included at startup and register routes programmatically:
// route/users.php
$app->route('/users/{id}', function ($id, $response) {
return User::find($id) ?: 404;
});
$app->route('/users/{id}/avatar', function ($id, $response) {
return $response->sendFile("storage/avatars/{$id}.png");
});
// route/admin.php — namespace prefix
$app->nsRoute('admin', '/users', fn() => Admin::userList());
// route/ws.php — WebSocket
$app->ws('/chat', $onMessage, $onOpen, $onClose);
Use it for: anything dynamic. URL params, WebSocket, namespace prefixes, regex routes, programmatic registration. One file can register many routes.
4. app.php — inline routes for one-offs
For a handful of trivial routes that don’t warrant a file, register them right in the bootstrap:
// app.php — before $app->run()
$app->route('/health', fn() => ['ok' => true]);
$app->route('/version', fn() => ['version' => '1.0.0']);
$app->run();
Keep this small. If app.php grows past ten inline routes, move them to a file in
route/.
The execution order — first match wins
When two routing styles could match the same URL, priority kicks in. Routes are checked in this order:
-
Explicit routes from
app.php(registered before$app->run()in your own code). -
Routes from
route/*.php(auto-included at the top of$app->run()). -
The
/api/*namespace → handed off toZealAPI::processApi(), which loads the matchingapi/<module>/<segment>.php(the URL segments pick the file — same file handles all HTTP methods). -
Dotfile + raw-
.php-URL guards → URLs containing.git/,.env,.htaccess, or ending in.phpreturn 403..well-known/is allowed (RFC 8615). -
Implicit
public/file lookup →/→public/index.php,/foo→public/foo.php,/dir/file→public/dir/file.php. -
Fallback or 404 → if you registered one with
App::setFallback(...), it runs. Otherwise: 404.
The practical consequence: explicit beats convention. If you register
$app->route('/about', ...) in route/, that closure wins over
public/about.php — even though the public file would have served too.
How /api/* is auto-delivered
The framework registers two implicit catch-all routes for the api namespace:
$this->nsPathRoute('api', '{module}/{rquest}', [...]); // /api/users/list
$this->nsPathRoute('api', '{rquest}', [...]); // /api/healthcheck
Both delegate to ZealAPI::processApi($module, $request). That class walks the
api/ directory, finds the matching api/<module>/<request>.php
file, includes it (which must define a single closure whose variable name matches
basename($file, '.php')), and invokes the closure with parameter injection.
The result: any method on /api/users/list — GET,
POST, PUT, DELETE — auto-runs
api/users/list.php’s $list closure. The framework does NOT
dispatch by HTTP verb; if your handler cares about the method, read
$request->server['REQUEST_METHOD'] inside the closure. You never register an
API route by hand — the framework builds the two catch-all dispatchers once at boot.
When nothing matches — fallback or 404
For unmatched URLs, ZealPHP’s default is a 404. If you want different behavior — for example, hand every unmatched URL to a WordPress installation — register a fallback:
// app.php
App::setFallback(function () {
// App::include() resolves the path relative to public/ (Apache DocumentRoot convention).
App::include('/wordpress/index.php');
});
This is the equivalent of Apache’s RewriteRule . /index.php [L]. It’s
what makes ZealPHP a drop-in runtime for legacy apps: the fallback hands every URL the framework
doesn’t recognize off to your legacy router.
Quick reference
| I want to serve… | Put the file in… | URL becomes |
|---|---|---|
| A static page | public/about.php | /about |
| A CSS or image asset | public/css/site.css | /css/site.css |
| A JSON API endpoint | api/users/list.php (closure named $list) | /api/users/list (any HTTP method) |
| A URL with a parameter | route/users.php via $app->route('/users/{id}', ...) | /users/42 |
| A WebSocket endpoint | route/ws.php via $app->ws('/chat', ...) | ws://host/chat |
| A pattern that captures regex | route/legacy.php via $app->patternRoute('/post/(\d+)', ...) | /post/123 |
| A catch-all for unmatched URLs | app.php via App::setFallback(...) | anything not matched above |
You have public/about.php (renders a marketing page) and you also register $app->route("/about", fn() => "from explicit") in route/marketing.php. A request hits GET /about. What runs?
Key Takeaways
- Three filesystem conventions:
public/for pages,api/for JSON endpoints,route/for anything dynamic. - Inline
$app->route()calls inapp.phpare for one-offs — move toroute/once you have more than a handful. - Priority order: explicit (app.php) → route/ → /api/* → dotfile/.php guards → public/ → fallback.
- The
api/tree is auto-dispatched viaZealAPI::processApi():api/users/list.php(closure named$list) auto-handles/api/users/listfor every HTTP method — the handler reads$request->server['REQUEST_METHOD']if it cares. - Use
App::setFallback()to catch unmatched URLs — perfect for hosting legacy apps under a ZealPHP front door.