REST API — File-Based
Drop a PHP file in api/ and it becomes a REST endpoint automatically. The file defines a closure whose variable name matches the filename. $this inside the closure is the ZealAPI instance (that's the class powering this — keep reading).
How it works
<?php
// File: api/device/list.php
// Endpoint: /api/device/list — Mode 1: EVERY HTTP method hits this ONE handler.
// The variable name MUST match basename($file, '.php') → 'list'
use ZealPHP\G;
$list = function() {
// $this is the ZealAPI instance. Because Mode 1 doesn't split GET/POST
// into separate closures, use the helper to tell which verb came in:
$method = $this->get_request_method(); // 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
if ($method === 'POST') {
$g = G::instance();
return ['created' => true, 'method' => $method, 'body' => $g->post];
}
// default (GET, HEAD, …)
return [
'devices' => [['id' => 1, 'name' => 'Sensor A'], ['id' => 2, 'name' => 'Sensor B']],
'method' => $method,
];
};
In Mode 1 a single closure answers every HTTP method, so use
$this->get_request_method() to branch on the verb (it returns
GET, POST, PUT, DELETE, or
PATCH). If you'd rather split each method into its own closure, use
per-method dispatch (Mode 2) instead.
File naming convention
| File | Variable | Endpoint | Notes |
|---|---|---|---|
api/device/list.php | $list | /api/device/list | Directory = module, filename = endpoint |
api/device/add.php | $add | /api/device/add | Handler decides what HTTP methods to accept |
api/learn/notes.php | $notes | /api/learn/notes | Responds to GET, POST, PUT, DELETE |
api/docs/search.php | $search | /api/docs/search | Filename is the action name |
.php). api/device/list.php defines $list = function() { ... };. ZealAPI binds it as a Closure with $this set to the ZealAPI instance. Every endpoint accepts GET, POST, PUT, DELETE, and PATCH.
Per-method dispatch
Inspired by Next.js App Router route handlers: instead of one catch-all closure, define $get, $post, $put, $delete, or $patch — each handles its HTTP method. Undefined methods return 405 Method Not Allowed automatically.
<?php
$get = function() {
return ['users' => [['id' => 1, 'name' => 'Alice']]];
};
$post = function() {
return ['created' => true, 'id' => 3];
};
// PUT, DELETE, PATCH → automatic 405 Method Not Allowed
// HEAD → auto-derived from $get (body stripped by framework)
// OPTIONS → auto-generated Allow header
| Request | What happens |
|---|---|
GET /api/users | Runs $get → JSON response |
POST /api/users | Runs $post → JSON response |
DELETE /api/users | 405 + Allow: GET, POST, HEAD, OPTIONS |
HEAD /api/users | Runs $get, body stripped |
api/list.php defines both $list (filename match) and $get/$post, the filename match wins and method handlers are unreachable. The framework logs a warning to debug.log when this happens.
$get/$post/… aren't globally "reserved" — but if the filename itself is an HTTP verb (e.g. api/php/get.php), then $get is a filename match (it equals ${basename(__FILE__, '.php')} and serves every method), not per-method dispatch. Filename match always wins, so a verb-named file can never do per-method routing. To dispatch by method, put the handlers in a non-verb-named file (api/users.php → $get/$post). The framework logs a debug.log warning when $get in get.php collapses to a filename match.
Return value conventions
API handlers ride the universal return contract — same shapes as route handlers, public files, App::render(), and App::include(). int = status, array = JSON, string = HTML, Generator = stream, ResponseInterface = PSR-7 used directly.
<?php
$list = function() {
return [
'products' => [
['id' => 1, 'name' => 'Widget', 'price' => 9.99],
['id' => 2, 'name' => 'Gadget', 'price' => 24.99],
],
'total' => 2,
];
};
// → {"products": [...], "total": 2}
Parameter injection
API handlers get the same parameter injection as route handlers. $req / $res are accepted as short aliases for $request / $response — they receive the exact same wrappers:
<?php
// File: api/user/show.php → /api/user/show
// Canonical convention: the closure variable name matches the filename
// (basename without .php). One handler serves EVERY HTTP method — branch
// on the verb with $this->get_request_method() if you need to.
use ZealPHP\RequestContext;
${basename(__FILE__, '.php')} = function($request, $response) {
// ZealAPI's dispatcher injects these by parameter name (reflection-cached):
// $request → ZealPHP\HTTP\Request (wrapped OpenSwoole request; alias: $req)
// $response → ZealPHP\HTTP\Response (wrapped OpenSwoole response; alias: $res)
// $app → ZealPHP\App instance (the main application instance)
// $server → \OpenSwoole\Http\Server (raw OpenSwoole server handle)
// $this → ZealAPI instance (handler runs inside Closure::bind)
// Any other named parameter receives its default value (or null if none).
// Pull a query param via the injected request — the cleanest form:
$id = $request->get['id'] ?? null;
// ^ ZealPHP\HTTP\Request->get is the OpenSwoole-parsed
// query array (per-request, NOT the $_GET superglobal).
if (!$id) return 400; // int return = HTTP status (universal contract)
// Need $g (session, cookies, server vars)? Grab it explicitly:
$g = RequestContext::instance();
if (empty($g->session['user'])) return 401;
return ['user' => User::find($id)]; // array return = JSON body (universal contract)
};
$_GET superglobal): $request->get['id'] (injected parameter, cleanest), RequestContext::instance()->get['id'] (useful when you also need $g->session), or $this->_request['id'] (legacy form — $this->_request is the sanitized input array ZealAPI builds via cleanInputs(), so index it directly: $this->_request['id'], not ->get['id']). Prefer the injected $request for new code. ZealAPI does NOT auto-inject $g by name — call RequestContext::instance() explicitly when you need it.
Streaming from APIs
API handlers can return Generators for streaming responses:
<?php
$stream = function() {
return (function() {
yield '{"events":[';
$first = true;
foreach (Event::cursor() as $event) {
if (!$first) yield ',';
yield json_encode($event->toArray());
$first = false;
}
yield ']}';
})();
};
$this methods (ZealAPI instance)
| Property / Method | Description |
|---|---|
$this->_request | Sanitized request input — GET (or GET+POST merged, or the parsed body) run through cleanInputs(). An array, not a request object. |
$this->_response | The ZealPHP\HTTP\Response wrapper (its ->parent is the raw OpenSwoole response). $this->response() writes through it. |
$this->request | The injected $request — the ZealPHP\HTTP\Request wrapper ($this->request->get = per-request query array). Note: no underscore, distinct from $this->_request. |
$this->paramsExists(['id', 'name']) | Check required params exist in GET/POST |
$this->response($data, $status) | Send response with status code |
$this->die($exception) | Handle exception and send error response |
$this->get_request_method() | Returns GET, POST, PUT, DELETE |
$this->setContentType($type) | Set response content type |
$this->isAuthenticated() | Consults App::authChecker(). Default false. See below. |
$this->isAdmin() | Consults App::adminChecker(). Default false. |
$this->getUsername() | Consults App::usernameProvider(). Default null. |
$this->requirePostAuth() | POST + authenticated guard. Returns false and emits 403 JSON on failure. |
Scoping middleware to API endpoints
ZealAPI files aren't individually registered routes, so there is no separate "api middleware" — api/admin/x.php is reached by the URL /api/admin/x, which flows through the same stack as everything else. Scope middleware by path with App::when() and it covers the api layer for free:
App::middlewareAlias('auth', fn() => new BasicAuthMiddleware($verifier));
App::when('/api/admin', ['auth', 'admin-only']); // every api/admin/*.php endpoint
App::when('/api', ['request-id']); // the whole /api/* surface
For a guard that belongs to one file, declare $middleware inline (read like $get/$post) — it runs innermost, closest to the handler:
$middleware = ['confirm-token']; // runs after App::when('/api/admin')['auth']
$delete = function () {
return ['deleted' => true];
};
Full model + ordering on the middleware page. Middleware gates the request ("should this reach the handler?"); the auth hooks below identify the authenticated user inside the handler — they compose.
Pluggable auth hooks v0.2.25
ZealAPI doesn't know what your auth system looks like — your app might use ZealPHP sessions, a Symfony bundle, the SelfMade Ninja stack, a custom OAuth flow, or JWT in a header. So the framework doesn't bake an auth check in. Instead it consults three optional callbacks you register on App. They default to fail-closed values (false, false, null) so endpoints guarded by requirePostAuth() reject everything until you wire them up.
| Setter | Signature | Consumed by | Default |
|---|---|---|---|
App::authChecker(?callable) | fn(): bool | ZealAPI::isAuthenticated() | false |
App::adminChecker(?callable) | fn(): bool | ZealAPI::isAdmin() | false |
App::usernameProvider(?callable) | fn(): ?string | ZealAPI::getUsername() | null |
Wire them once, in your app's boot file (or in a framework wrapper's bootstrap if you're shipping a multi-app platform). Every ZealAPI handler downstream inherits the answers — no per-handler boilerplate.
<?php
use ZealPHP\App;
require __DIR__ . '/vendor/autoload.php';
// Register the three hooks ONCE during boot. ZealPHP doesn't care what
// auth system you use — it just asks you. Callbacks run on each
// requirePostAuth() / isAuthenticated() / isAdmin() / getUsername() call,
// so you can read $_SESSION / $g->session / a JWT header / a global —
// whatever lives at the moment the API handler dispatches.
// Canonical: read via $g — works in BOTH App::superglobals modes.
App::authChecker(fn(): bool => !empty(\ZealPHP\G::instance()->session['user_id']));
App::adminChecker(fn(): bool => (\ZealPHP\G::instance()->session['role'] ?? '') === 'admin');
App::usernameProvider(fn(): ?string => \ZealPHP\G::instance()->session['username'] ?? null);
$app = App::init('0.0.0.0', 8080);
$app->run();
<?php
// File: api/device/delete.php — handler filters by method internally
$delete = function() {
// POST + authenticated guard. Sends 403 JSON and returns false if
// either check fails — short-circuits the handler.
if (!$this->requirePostAuth()) return;
// Admin-only operation? Compose checks naturally:
if (!$this->isAdmin()) {
return $this->response($this->json(['error' => 'admin_only']), 403);
}
$username = $this->getUsername(); // for audit log
$g = \ZealPHP\G::instance(); // per-coroutine; safe in both modes
User::delete($g->post['id'], $username);
return ['ok' => true];
};
isAuthenticated(); a few need isAdmin() too; a smaller subset wants getUsername() for logging. Three closures means the trivial case is one line, the polished case is three. The setters follow the existing App::superglobals() / App::sessionLifecycle() fluent precedent — same shape, same lifecycle (configure before App::init(), queried by ZealAPI at request time). See the auth lesson for a worked example with a real session.
Live ZealAPI endpoints
GET /api/php/sapi_name — returns SAPI name
// api/php/sapi_name.php
$sapi_name = function() {
return ['sapi' => php_sapi_name(), 'async' => true];
};
GET /api/php/get — dump GET params
// api/php/get.php → /api/php/get
// NOTE: $get here is a FILENAME MATCH (the file is named get.php), NOT
// per-method dispatch. ${basename(__FILE__, '.php')} resolves to $get and
// serves every HTTP method. See the gotcha under "Per-method dispatch".
${basename(__FILE__, '.php')} = function() {
$g = G::instance();
return ['query_params' => $g->get, 'async' => php_sapi_name() === 'cli'];
};
Error responses
All ZealAPI failures emit JSON with an error key and an HTTP status code. Use the error string to branch in client code; the hint is for humans.
| Status | error | When |
|---|---|---|
400 | invalid_module | Path component fails the strict [a-zA-Z0-9_/-] regex (prevents traversal) |
400 | invalid_request | Method name contains characters other than [a-zA-Z0-9_\-] |
404 | method_not_found | Handler file missing, or the expected closure variable name does not exist in the file |
404 | handler_not_found | Per-method dispatch: no matching $get/$post/… variable found in the file (file exists but the method handler is absent) |
404 | undefined_method | Handler called $this->X() but X is not a method on ZealAPI/REST |
405 | method_not_allowed | Per-method dispatch: the file defines some method handlers (e.g. $get) but not the one the client used — Allow header lists the defined methods |
500 | — | Uncaught throwable inside the handler — stack trace is logged via elog() |
Typo detection — undefined_method
When you call a method that doesn't exist on $this from inside a handler, ZealAPI no longer hangs (it used to recurse on __call until stack overflow). It returns 404 with a structured error and, when the typo is close to a real method, a did_you_mean hint computed via levenshtein.
GET /api/bug/bad — handler typos $this->paramExist (real method is paramsExists)
// api/bug/bad.php
$bad = function($request) {
if ($this->paramExist(['id'])) { // ← typo (real method is paramsExists)
return ['id' => $request->get['id'] ?? 'n/a'];
}
};
If the typo is too far from any real method (more than 3 edits, or above 40% of the name length), the did_you_mean field is omitted to avoid misleading suggestions — only the error, method, and a generic hint are returned.
Implicit public/ file serving
Files in public/ are served automatically — no route definition needed:
| File | URL | How |
|---|---|---|
public/index.php | / | Root route |
public/about.php | /about | Filename → path (no .php) |
public/admin/index.php | /admin/ | Directory index |
public/admin/users.php | /admin/users | Nested path |
public/css/style.css | /css/style.css | Static file (served by OpenSwoole directly) |
<?php use ZealPHP\App;
App::render('_master', [
'title' => 'About Us',
'page' => 'about',
]);
<?php
use ZealPHP\App;
// Return a Generator → streams to browser
return (function() {
yield App::renderToString('shell-open',
['title' => 'Dashboard']);
yield "<h1>Dashboard</h1>";
yield App::renderToString('shell-close');
})();
Public files ride the universal return contract — same shapes as a route handler.
public/ are served directly by OpenSwoole's enable_static_handler — they never hit PHP. Only .php files are processed by ZealPHP.
Task workers
Task workers run CPU-intensive or background work without blocking HTTP workers. Dispatch tasks from any request handler; task handlers live in task/.
<?php
// File: task/backup.php
// The variable name must match basename → 'backup'
use function ZealPHP\elog;
$backup = function($db_name, $output_dir) {
elog("Starting backup of $db_name to $output_dir");
// Heavy work here — runs in task worker, not HTTP worker
$file = "$output_dir/$db_name-" . date('Ymd-His') . ".sql";
exec("mysqldump $db_name > $file");
return ['status' => 'done', 'file' => $file];
};
use ZealPHP\App;
$app->route('/admin/backup', ['methods' => ['POST']], function() {
// Dispatch to task worker (non-blocking)
App::getServer()->task([
'handler' => '/task/backup',
'args' => ['my_database', '/backups'],
]);
return ['queued' => true, 'message' => 'Backup started in background'];
});
Task worker configuration
$app->run([
'task_worker_num' => 4, // 4 dedicated task workers
'task_enable_coroutine' => true, // Coroutines in task workers (default)
]);
| Concept | Detail |
|---|---|
| Handler naming | File task/backup.php defines $backup = function(...) { ... } |
| Dispatch | App::getServer()->task(['handler' => '/task/backup', 'args' => [...]]) |
| Return value | Received in the finish callback (logged by default) |
| Coroutines | Task workers run in coroutine mode — go(), channels, async I/O all work |
| Blocking safety | Tasks run in separate worker processes — CPU-bound work doesn't block HTTP |
task_worker_num in $app->run() if you use task dispatch. Without task workers, $server->task() will fail silently.