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.

HTTP Responses

ZealPHP wraps OpenSwoole's response with a clean API. Every method is coroutine-safe — no output buffering leaks across concurrent requests.

Universal return contract

One contract, every entry point. Any function that produces a response — route handler, fallback, error handler, App::render(), App::renderToString(), App::renderStream(), App::include(), public file, API closure, streaming template Closure — uses the same return contract. The framework translates the return value into an HTTP response identically regardless of where it came from. Other pages link here rather than restating it.

The handler / file doesCore seesResponseMiddleware emits
echo "html"; // no explicit return"html" (buffered)200 + HTML body
return 404;404 (int)404 status, empty body
return ['ok' => true];['ok' => true] (array)200 + JSON (Content-Type: application/json)
return "explicit html";"explicit html" (string)HTML body
echo "shell"; return "body";"shellbody" (concatenated)HTML body (wire order preserved)
return (function() { yield ...; })();\GeneratorSSR stream — each yield flushed
return function($req) { yield ...; };\Closure (param-injected when invoked)SSR stream after invocation
echo "header"; return (function() { yield ...; })();\Generator wrapping "header" + delegated yieldsStreamed in source order
return new Response($body, 200);ResponseInterfacePSR-7 response used directly (output buffer ignored)

Lock-step: this table is mirrored verbatim in .claude/CLAUDE.md under "Return value conventions". Any change to return-value handling MUST update both in the same commit. The shared private core that implements this is App::executeFile().

All return patterns in one glance
// Status code
$app->route('/not-found', fn() => 404);

// JSON (array or object)
$app->route('/api/user/{id}', fn($id) => ['id' => $id, 'name' => 'alice']);

// HTML string
$app->route('/hello', fn() => '<h1>Hello World</h1>');

// Generator streaming
$app->route('/stream', fn() => (function() {
    yield '<html><body>';
    yield '<h1>Streamed!</h1>';
    yield '</body></html>';
})());

// Echo (output buffering)
$app->route('/echo', function() {
    echo '<div>This is captured</div>';
    echo '<div>by output buffering</div>';
});

// Same contract inside an included file:
//   public/article.php → return 404;        // status flows back
//   public/api.php     → return ['ok'=>1];  // JSON flows back
//   public/feed.php    → return (function(){ yield ...; })();  // streamed
$app->route('/article/{id}', fn($id) => App::include('/article.php'));

The same contract applies inside any file invoked by App::render() / renderToString() / renderStream() / include() — and inside any region declared by App::fragment() when extracted. See the file-execution family for the full method table.

Valid HTTP status codes

When the contract says int = HTTP status, the int must be in the range 100–599 (RFC 7230 — three-digit response codes). ZealPHP supports every IANA-registered code in that range, including the long-tail ones like 418, 421, 423, 425, 451, 507, 511.

What if you return something outside that range?

You returnWhat happens
return 0; / return -1; / return 42; / return 999;Coerced to 500 Internal Server Error with a warning logged via elog(). Matches Apache HTTP server behaviour (Apache silently coerces out-of-range codes to 500). The log entry surfaces the bug instead of letting it silently fail in production.
return 1; (special case)That's what PHP's include returns by default when a file has no explicit return statement. Inside App::include() / App::render() / App::renderToString() / App::renderStream(), a 1 return is treated as "no explicit return" — the framework surfaces the buffered echo as the response body instead of trying to set HTTP status 1. The same return value from a plain route handler (a closure) IS treated as an int status — and since 1 is outside the valid 100–599 range, it's coerced to 500 (identical to the row above). The "no explicit return" special-case applies only inside the file-execution family (include()/render()/…), not to closure return values. If you ever explicitly mean "return 1 as a status," return 100 or another in-range code instead.
return null;"No status override, no body override" — the response defaults to 200 with whatever body the framework computed (usually empty).

Edge cases worth knowing

  • 600–999 are coerced to 500 (same as 0 / -1 / 42 / 999) and logged — only 100–599 emit as-is. App::coerceStatusCode() returns a status only when $status >= 100 && $status < 600; anything outside that range becomes 500 with a warning in the debug log.
  • Reason phrases for non-standard codes default to empty. The wire format is still HTTP/1.1 451\r\n, just without "Unavailable For Legal Reasons" after the digits. Browsers don't display reason phrases, so this is cosmetic.
  • Returning null means "no status override, no body override" — same as a handler that doesn't return at all.
Ops note. Out-of-range coercion writes to ZealPHP's debug log (ZEALPHP_DEBUG_LOG or /tmp/zealphp/debug.log by default). Grep for Invalid HTTP status code returned: to surface handlers that are silently bouncing to 500 in production.
How the framework rescues codes OpenSwoole's native list rejects. OpenSwoole 22.1.5's single-arg $response->status($code) silently downgrades certain IANA codes (notably 425 Too Early and 451 Unavailable For Legal Reasons) to HTTP/1.1 200 OK on the wire — its C-side whitelist predates RFCs 8470 and 7725. ZealPHP works around this via an internal App::emitStatus() helper that uses OpenSwoole's two-arg form $response->status($code, $reason), threading the IANA reason phrase from REASON_PHRASES. Every IANA-registered status in 100–599 now emits correctly. (Niche nginx-extension codes like 444 and 499 that aren't in REASON_PHRASES still fall back to the single-arg form and may downgrade; add them to REASON_PHRASES if you need them.)

Response Object Methods

MethodSignatureWhat it does
json()json($data, $status=200)Sets Content-Type: application/json, encodes and ends response
redirect()redirect($url, $status=302)Sets Location header + status, no body
header()header($key, $value)Queues response header (sent on flush)
cookie()cookie($name, $value, ..., $samesite)Sets cookie with full attributes incl. SameSite
status()status(int $code)Sets HTTP status code
stream()stream(callable $fn)Flush headers immediately, stream body via $write() closure
sse()sse(callable $fn)Server-Sent Events — sets event-stream headers, $emit() closure
sendFile()sendFile($path, $filename='')Zero-copy file serving with Range support; sets Content-Disposition when filename given
end()end(?string $data)Send final body and close connection
GET json() — returns JSON with status 200
$app->route('/demo/response/json', function() {
    return ['framework' => 'ZealPHP', 'async' => true, 'time' => time()];
    // Returning an array auto-sets Content-Type: application/json
});
LIVE OUTPUT Click Run →
GET redirect() — 301 permanent redirect
$app->route('/demo/response/redirect-301', function($response) {
    $response->redirect('/routing', 301);
});
LIVE OUTPUT Click Run →
GET header() — custom response headers
$app->route('/demo/response/headers', function($response) {
    $response->header('X-Framework',  'ZealPHP');
    $response->header('X-Async',      'true');
    $response->header('Cache-Control','no-store');
    return ['headers_set' => ['X-Framework', 'X-Async', 'Cache-Control']];
});
LIVE OUTPUT Click Run →
Streaming responses — stream() and sse() are covered on the Streaming page. They send headers immediately and bypass the PSR-7 output buffer.

PSR-7 Response objects

Return a PSR-7 Response directly when you need full control over status, headers, and body in one shot. The output buffer is ignored.

Return new Response(...)
use OpenSwoole\Core\Psr\Response;
use ZealPHP\G;

$app->route('/coglobal/set/session', ['methods' => ['GET', 'POST']], function($name) {
    // This echo is IGNORED when a Response object is returned
    echo "Hello World";

    $g = G::instance();
    $g->session['name'] = $name;

    return new Response(
        'Session set',           // body
        300,                     // status
        'success',               // reason phrase
        ['Content-Type' => 'text/plain', 'X-Test' => 'test']
    );
});

Bypass the output buffer

Use $response->status() + $response->write() when you want to send the response immediately and skip output buffering entirely.

$response->write() takes precedence
$app->patternRoute('/.*\.php', ['methods' => ['GET', 'POST']], function($response) {
    $response->status(403);
    $response->write("403 Forbidden");
    // No return needed — write() ends the response. Output buffer ignored.
});

Utility functions

Free functions that work in any route handler — no need to grab $response first:

FunctionEquivalent to
response_set_status(int $code)$response->status($code)
response_add_header(string $name, string $value)$response->header($name, $value)
Utility functions in action
use function ZealPHP\response_add_header;
use function ZealPHP\response_set_status;

$app->route('/api/created', function() {
    response_add_header('Location', '/api/items/42');
    response_set_status(201);
    return ['id' => 42, 'created' => true];
});

Custom error pages — Apache ErrorDocument

Register a handler for any 4xx/5xx status. Fires whenever the framework or a route emits that status. Return values follow the universal return contract — the same shapes a route handler returns work here too.

Status-specific + catch-all handlers
use ZealPHP\App;

$app = App::instance();

// Status-specific
$app->setErrorHandler(404, function($status) {
    return App::renderToString('error/404', ['status' => $status]);
});

$app->setErrorHandler(500, function($exception) {
    return [
        'error'    => 'Internal Server Error',
        'trace_id' => uniqid('e_'),
    ];
});

// Catch-all — fires when no status-specific handler matches
$app->setErrorHandler(function($status, $exception) {
    http_response_code($status);
    echo "<pre>Error $status</pre>";
});
ParamValue
$statusThe HTTP status being rendered (int).
$exceptionThe caught \Throwable for 500-from-throw paths; null otherwise.
$request / $responseWrappers around the OpenSwoole request/response.

A handler that itself throws is caught — the framework falls through to the default body for the original status (not 500). Recursion is guarded by G->error_render_depth.

Sites where the handler fires:

  • return 404; from any route handler (or 4xx/5xx int return).
  • Uncaught \Throwable from a route handler — 500.
  • exit(1) / die(1) — 500.
  • URL-encoded traversal — 400.
  • Dotfile requests, .php direct access, includeCheck rejection — 403.
  • Unmatched URL with no fallback — 404.

Default error pages — content negotiation

When no custom handler is registered, the framework emits HTML by default and JSON when the client sends Accept: application/json:

JSON envelope
{
  "error": {
    "status": 500,
    "message": "Internal Server Error",
    "trace": "RuntimeException: boom at ...\n at App.{closure}(...)"
  }
}

trace is populated only when App::$display_errors is true. Custom handlers override negotiation entirely — user intent trumps Accept.

Per-coroutine error handlers

set_error_handler, set_exception_handler, register_shutdown_function, and error_reporting() are process-global in vanilla PHP — one coroutine's call would catch every other's errors. ZealPHP isolates them per request via G:

Per-coroutine handler — fires only for THIS request
$app->route('/process', function() {
    register_shutdown_function(function() {
        zlog('request finished', 'info');
    });

    set_error_handler(function($severity, $msg, $file, $line) {
        // captures warnings/notices in THIS coroutine only
        return true;
    }, E_WARNING | E_NOTICE);

    // ... handler body ...
});

A native process-level handler installed at boot delegates to the active coroutine's G stack. register_shutdown_function's queue is drained AFTER the route returns and BEFORE the PSR response is emitted — so shutdown functions can still echo or call http_response_code() and have those land in the wire response.

For the full mechanism (boot-order trick, exception-handler integration in dispatchRoute's catch, recursion guard, source-line references), see docs/error-handling.md.