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.

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

api/device/list.php → /api/device/list
<?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

FileVariableEndpointNotes
api/device/list.php$list/api/device/listDirectory = module, filename = endpoint
api/device/add.php$add/api/device/addHandler decides what HTTP methods to accept
api/learn/notes.php$notes/api/learn/notesResponds to GET, POST, PUT, DELETE
api/docs/search.php$search/api/docs/searchFilename is the action name
The variable name must match the filename (without .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.

api/users.php — one file, per-method handlers
<?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
RequestWhat happens
GET /api/usersRuns $get → JSON response
POST /api/usersRuns $post → JSON response
DELETE /api/users405 + Allow: GET, POST, HEAD, OPTIONS
HEAD /api/usersRuns $get, body stripped
Don't mix both conventions in one file. If 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.
Watch the verb-named file gotcha. $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.

api/products/list.php — return array for JSON
<?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:

Magic parameters: $request, $response, $app, $server (auto-injected by name)
<?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)
};
Three equivalent ways to read query params inside an API handler — all are per-request safe (none touch the process-wide $_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:

api/feed/stream.php — streaming JSON array
<?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 / MethodDescription
$this->_requestSanitized request input — GET (or GET+POST merged, or the parsed body) run through cleanInputs(). An array, not a request object.
$this->_responseThe ZealPHP\HTTP\Response wrapper (its ->parent is the raw OpenSwoole response). $this->response() writes through it.
$this->requestThe 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.php — path-scoped middleware covers the api layer
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:

api/admin/users/delete.php — co-located guard
$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.

SetterSignatureConsumed byDefault
App::authChecker(?callable)fn(): boolZealAPI::isAuthenticated()false
App::adminChecker(?callable)fn(): boolZealAPI::isAdmin()false
App::usernameProvider(?callable)fn(): ?stringZealAPI::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.

app.php — wiring ZealAPI auth to your own session
<?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();
api/device/delete.php — handler-side use
<?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];
};
Why three orthogonal setters instead of one auth-provider interface? Most apps need only 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 GET /api/php/sapi_name — returns SAPI name
// api/php/sapi_name.php
$sapi_name = function() {
    return ['sapi' => php_sapi_name(), 'async' => true];
};
LIVE OUTPUT Click Run →
GET 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'];
};
LIVE OUTPUT Click Run →

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.

StatuserrorWhen
400invalid_modulePath component fails the strict [a-zA-Z0-9_/-] regex (prevents traversal)
400invalid_requestMethod name contains characters other than [a-zA-Z0-9_\-]
404method_not_foundHandler file missing, or the expected closure variable name does not exist in the file
404handler_not_foundPer-method dispatch: no matching $get/$post/… variable found in the file (file exists but the method handler is absent)
404undefined_methodHandler called $this->X() but X is not a method on ZealAPI/REST
405method_not_allowedPer-method dispatch: the file defines some method handlers (e.g. $get) but not the one the client used — Allow header lists the defined methods
500Uncaught 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 GET /api/bug/bad — handler typos $this-&gt;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'];
    }
};
LIVE OUTPUT Click Run →

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:

FileURLHow
public/index.php/Root route
public/about.php/aboutFilename → path (no .php)
public/admin/index.php/admin/Directory index
public/admin/users.php/admin/usersNested path
public/css/style.css/css/style.cssStatic file (served by OpenSwoole directly)
public/about.php — 3-line page
<?php use ZealPHP\App;
App::render('_master', [
    'title' => 'About Us',
    'page'  => 'about',
]);
public/dashboard.php — streaming page
<?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.

Static files (CSS, JS, images, fonts) in 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/.

task/backup.php — define a task handler
<?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];
};
Dispatch from a route handler
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

Enable task workers in app.php
$app->run([
    'task_worker_num' => 4,            // 4 dedicated task workers
    'task_enable_coroutine' => true,   // Coroutines in task workers (default)
]);
ConceptDetail
Handler namingFile task/backup.php defines $backup = function(...) { ... }
DispatchApp::getServer()->task(['handler' => '/task/backup', 'args' => [...]])
Return valueReceived in the finish callback (logged by default)
CoroutinesTask workers run in coroutine mode — go(), channels, async I/O all work
Blocking safetyTasks run in separate worker processes — CPU-bound work doesn't block HTTP
Default: 0 task workers. Set task_worker_num in $app->run() if you use task dispatch. Without task workers, $server->task() will fail silently.