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.

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:

  1. Explicit routes from app.php (registered before $app->run() in your own code).
  2. Routes from route/*.php (auto-included at the top of $app->run()).
  3. The /api/* namespace → handed off to ZealAPI::processApi(), which loads the matching api/<module>/<segment>.php (the URL segments pick the file — same file handles all HTTP methods).
  4. Dotfile + raw-.php-URL guards → URLs containing .git/, .env, .htaccess, or ending in .php return 403. .well-known/ is allowed (RFC 8615).
  5. Implicit public/ file lookup/public/index.php, /foopublic/foo.php, /dir/filepublic/dir/file.php.
  6. 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/listGET, 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 pagepublic/about.php/about
A CSS or image assetpublic/css/site.css/css/site.css
A JSON API endpointapi/users/list.php (closure named $list)/api/users/list (any HTTP method)
A URL with a parameterroute/users.php via $app->route('/users/{id}', ...)/users/42
A WebSocket endpointroute/ws.php via $app->ws('/chat', ...)ws://host/chat
A pattern that captures regexroute/legacy.php via $app->patternRoute('/post/(\d+)', ...)/post/123
A catch-all for unmatched URLsapp.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 in app.php are for one-offs — move to route/ 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 via ZealAPI::processApi(): api/users/list.php (closure named $list) auto-handles /api/users/list for 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.