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 does | Core sees | ResponseMiddleware 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 ...; })(); | \Generator | SSR 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 yields | Streamed in source order |
return new Response($body, 200); | ResponseInterface | PSR-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().
// 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 return | What 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
nullmeans "no status override, no body override" — same as a handler that doesn'treturnat all.
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.
$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
| Method | Signature | What 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 |
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
});
redirect() — 301 permanent redirect
$app->route('/demo/response/redirect-301', function($response) {
$response->redirect('/routing', 301);
});
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']];
});
cookie() — SameSite cookie
$app->route('/demo/response/cookie', function($response) {
// Full PHP 7.3+ signature including SameSite
$response->cookie('session_demo', 'abc123', 0, '/', '', false, true, 'Strict');
return ['cookie_set' => 'session_demo=abc123; SameSite=Strict; HttpOnly'];
});
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.
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.
$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:
| Function | Equivalent to |
|---|---|
response_set_status(int $code) | $response->status($code) |
response_add_header(string $name, string $value) | $response->header($name, $value) |
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.
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>";
});
| Param | Value |
|---|---|
$status | The HTTP status being rendered (int). |
$exception | The caught \Throwable for 500-from-throw paths; null otherwise. |
$request / $response | Wrappers 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
\Throwablefrom a route handler — 500. exit(1)/die(1)— 500.- URL-encoded traversal — 400.
- Dotfile requests,
.phpdirect 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:
{
"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:
$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.
dispatchRoute's catch, recursion guard, source-line references), see docs/error-handling.md.