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.

Error Handling

ZealPHP provides three layers of error-handling parity with Apache+mod_php:

  1. Custom error pages — Apache ErrorDocument equivalent (App::setErrorHandler()).
  2. Per-coroutine PHP handlersset_error_handler, set_exception_handler, register_shutdown_function are isolated per request despite being process-global in vanilla PHP.
  3. Content negotiation — default error bodies respect Accept: application/json for API clients.

For the broader Apache parity surface (uopz overrides, public/ routing, sendFile, CGI worker), see apache-parity.md.


Overview

mod_php apps assume two things ZealPHP must emulate:

  • One PHP process per request. Setting set_error_handler() in one request shouldn't catch warnings in another. register_shutdown_function() should run at the end of THIS request, not when the worker dies.
  • ErrorDocument N /path in .htaccess lets you wire a custom page for any HTTP status. ZealPHP exposes this as App::setErrorHandler($status, $handler).

Both rely on per-coroutine state in G plus a single process-level native handler installed at boot that delegates to the active coroutine's stack.


App::setErrorHandler() — Apache ErrorDocument equivalent

// 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()];
});

// Catch-all (fires when no status-specific handler matches)
$app->setErrorHandler(function($status, $exception) { /* ... */ });

Handler param injection

The handler is dispatched through the same ResponseMiddleware::dispatchRoute() machinery as a regular route. Param injection by name:

Param Value
$status The error status code being rendered (int).
$exception The caught \Throwable (only present for 500 cases originating from a throw; null otherwise).
$request ZealPHP\HTTP\Request wrapper ($req is accepted as a short alias).
$response ZealPHP\HTTP\Response wrapper ($res is accepted as a short alias).
$app The middleware instance (rarely needed).

Handler return values

Same conventions as a normal route handler — see routing.md:

Return Effect
string HTML body.
array / object JSON-serialized, Content-Type: application/json.
\Generator Streaming response (writes chunks via OpenSwoole\Response::write(), ends inline).
Psr\Http\Message\ResponseInterface Used directly.
void + echo Output buffer captured and used as body.
int (4xx/5xx) Re-routes through renderError() — but inside a handler this triggers the recursion guard and returns the framework default.

The error status is seeded into $g->status before dispatch, so a handler returning a plain string produces a Response with the right status code. Handlers can override via http_response_code() inside the body.

When handlers fire

renderError($status, ?$exception) is called from every error site in the framework:

Site Status Trigger
dispatchRoute is_int branch as returned return 404; from any route handler
dispatchRoute catch 500 uncaught Throwable from a route handler
dispatchRawRoute catch 500 same, for raw routes
dispatchRoute exit-nonzero 500 exit(1) / die(1) from a handler
Top-level on('request') catch 500 exceptions outside route dispatch
.php block route 403 explicit .php URL when App::$ignore_php_ext
Dotfile pattern route 403 URL with dotfile component
3× implicit-route 403 branches 403 includeCheck() reject
URL-decoded traversal check 400 .., \0, backslash in path
invokeFallbackOrNotFound() no-fallback branch 404 unmatched URL when no fallback set
ResponseMiddleware::process() final 404 404 unmatched URL (no implicit/explicit/fallback match)

In every case, the handler return flows back through dispatchRoute's ResponseInterface branch and emits as a normal PSR response.


App::renderError() — the central dispatcher

App::renderError(int $status, ?\Throwable $exception = null): ResponseInterface:

1. Read G->error_render_depth — if >= 1, skip dispatch, go straight to defaultErrorResponse.
   (Recursion guard: a handler that triggers another error doesn't loop.)
2. Look up handler: status-specific → catch-all → null.
3. If found: seed G->status = $status, increment error_render_depth, dispatch via
   ResponseMiddleware::dispatchRoute([handler, param_map, raw], ['status' => $status, 'exception' => $exception]).
4. On catch: log, decrement depth, fall through.
5. defaultErrorResponse — content-negotiated HTML or JSON body.

Recursion guard

When a 500 handler itself throws, naively the dispatchRoute catch would call renderError(500, $e) again — infinite recursion. The guard:

  • renderError increments G->error_render_depth before dispatch.
  • dispatchRoute's catch checks G->error_render_depth > 0 and rethrows instead of calling renderError again.
  • The throw propagates back to renderError's own try/catch, which falls to defaultErrorResponse() for the ORIGINAL status (not 500).
  • After dispatch (success or exception), depth is decremented in a finally.

So a 502 handler that throws → the framework returns the default 502 page, not a default 500 page.

Default body — content negotiation

When no handler is registered (or the registered one threw), App::defaultErrorResponse() inspects request Accept:

$wantsJson = str_contains($accept, 'application/json')
          && !str_contains($accept, 'text/html');
  • JSON: {"error": {"status": 500, "message": "Internal Server Error", "trace": "..."}} — trace populated only when App::$display_errors.
  • HTML: <pre>{status} {reason}</pre> plus optional trace block.

Reason phrases come from App::REASON_PHRASES, which covers every IANA-registered code in the 100–599 range — including long-tail codes such as 425 (Too Early), 451 (Unavailable For Legal Reasons), 308 (Permanent Redirect), 511 (Network Authentication Required), and the full 1xx informational set. Custom handlers override negotiation — user intent trumps Accept.


Per-coroutine error/exception/shutdown handlers

Storage

Three properties on G:

public array $error_handlers_stack = [];      // [[callable, levels], ...]
public array $exception_handlers_stack = [];  // [callable, ...]
public array $shutdown_functions = [];        // [[callable, args], ...]

Namespaced overrides (ext-zealphp preferred, uopz fallback)

In src/utils.php:

Function Effect on G
\ZealPHP\set_error_handler($cb, $levels) push [$cb, $levels] onto stack; return previous top callable
\ZealPHP\restore_error_handler() pop stack
\ZealPHP\set_exception_handler($cb) push onto stack; return previous
\ZealPHP\restore_exception_handler() pop
\ZealPHP\register_shutdown_function($cb, ...args) append [$cb, $args] to queue
\ZealPHP\error_reporting($level) read/write G->error_reporting_level (defaults to App::$initial_error_reporting)

Each registered via App::overrideBuiltin() in App::__construct() — which prefers zealphp_override() when ext-zealphp is loaded and falls back to uopz_set_return() otherwise.

Process-level native dispatcher — installed BEFORE uopz

Order matters. At the very top of App::__construct():

self::$initial_error_reporting = \error_reporting();   // capture before uopz overrides it

\set_error_handler(static function ($severity, $message, $file, $line) {
    $g = G::instance();
    $level = $g->error_reporting_level ?? App::$initial_error_reporting;
    if (!($severity & $level)) return true;            // suppressed per-coroutine
    $stack = $g->error_handlers_stack;
    if (!empty($stack)) {
        [$callable, $levels] = $stack[count($stack) - 1];
        if ($severity & $levels) {
            try { return (bool)$callable($severity, $message, $file, $line); }
            catch (\Throwable $e) { return false; }   // avoid loops
        }
    }
    return false;                                       // PHP default
});

\set_exception_handler(static function (\Throwable $e) {
    $g = G::instance();
    $stack = $g->exception_handlers_stack;
    if (!empty($stack)) {
        try { $stack[count($stack) - 1]($e); } catch (\Throwable $e2) {}
    }
});

// App::overrideBuiltin() calls follow (prefer zealphp_override() when ext-zealphp is loaded, uopz_set_return() otherwise) ...

After uopz installs, user-space set_error_handler(...) writes to G instead of overwriting the native handler. Engine-raised errors still flow through the bootstrap dispatcher, which now reads per-coroutine state.

Exception handler wired into dispatchRoute

set_exception_handler normally only fires for uncaught exceptions — but ZealPHP's dispatchRoute catches everything before they bubble out. To make the API useful, both catch blocks check G->exception_handlers_stack before falling through to renderError:

} catch (\Throwable $e) {
    if ($e instanceof ExitException) { /* ... */ }

    // Inside error-render recursion → rethrow (handled by outer renderError).
    if (($g->error_render_depth ?? 0) > 0) { @ob_end_clean(); throw $e; }

    // User-installed exception handler runs before default error page.
    $excStack = $g->exception_handlers_stack ?? [];
    if (!empty($excStack)) {
        ob_start();
        try { $excStack[count($excStack) - 1]($e); } catch (\Throwable $e2) {}
        $body = ob_get_clean();
        return (new Response($body))->withStatus($g->status ?? 500);
    }

    return App::instance()->renderError(500, $e);
}

Per-request shutdown function lifecycle

Inside the on('request') handler, after the middleware stack returns but BEFORE PSR emit:

1. capture $serverResponse = middleware->handle(...)
2. read $g->shutdown_functions queue
3. ob_start()  (nested under any existing buffer)
4. for each [fn, args]: try fn(...args); catch -> log
5. clear $g->shutdown_functions = []
6. capture ob_get_clean() — if non-empty, append to $serverResponse body
7. if $g->status was changed by a shutdown fn -> $serverResponse->withStatus($g->status)
8. emit normally

This lets shutdown functions still mutate the response — they can echo, call http_response_code(503), or modify state. Mod_php semantics expect output and headers to still be settable from shutdown functions, so we run them before the wire bytes leave.

The $body . $extra write uses a fresh php://temp stream wrapped in OpenSwoole\Core\Psr\Stream because the PSR Stream interface doesn't expose a way to append to an existing body.


Important caveats

Handler registration is process-global

App::setErrorHandler() writes to App::$error_handlers — a STATIC array. Registering a handler from inside a request mutates the global registry for ALL subsequent requests on this worker. Recommended pattern: register handlers at boot (in app.php or a route fixture file), not from inside route handlers.

Tests must avoid handler-from-route mutation

Integration tests that need to verify handler-specific shapes (array return, Generator return) should either (a) use a status code that's unused elsewhere, or (b) sub-dispatch by URI inside the globally-registered handler. The test fixture demonstrates pattern (b): one setErrorHandler(404, ...) is registered at fixture load, and it dispatches by URI suffix internally.

Status codes OpenSwoole drops

OpenSwoole's single-arg Response::status($code) honors only the codes in its internal reason-phrase table; codes like 308 and 451 would silently downgrade to 200 when passed that way. The framework's PSR emit boundary calls App::emitStatus(), which looks up the IANA reason phrase from App::REASON_PHRASES and uses OpenSwoole's two-arg $response->status($code, $reason) form — so every code in REASON_PHRASES (including 308, 425, 451, 421, 423, 507, 511, and more) emits correctly with no user workaround required.

The only codes that may still need manual handling are niche non-IANA codes absent from REASON_PHRASES (e.g. nginx 444/499). For those, call $response->parent->status($code, 'Your Reason') directly in your handler.

Generator status preservation

Response::flush() clears G->status to null as part of pushing headers to OpenSwoole. For Generator-returning handlers, dispatchRoute captures the status BEFORE calling flush:

if ($object instanceof \Generator) {
    $streamStatus = $g->status ?? 200;             // capture FIRST
    $g->openswoole_response->status($streamStatus);
    $g->zealphp_response->header('Accept-Ranges', 'none');
    $g->zealphp_response->flush();                 // safe to clear g->status now
    foreach ($object as $chunk) { ... }
    return (new Response('', $streamStatus));
}

Both dispatchRoute and dispatchRawRoute apply this pattern.


End-to-end flow

Custom 404 from return 404;

Route handler returns 404
    -> dispatchRoute is_int branch: $istatus=404, in [400,600) → App::instance()->renderError(404)
        -> renderError: depth=0, look up 404 handler → found
            -> seed G->status=404, depth=1
            -> ResponseMiddleware->dispatchRoute([handler, param_map, raw], ['status'=>404,'exception'=>null], 'GET')
                -> Inner dispatch: ob_start, call handler($status=404, ...)
                -> Handler returns "CUSTOM-404-BODY" (string)
                -> Inner dispatch is_string branch: ob_end_clean, return Response("CUSTOM-404-BODY", 404)
            <- ResponseInterface returned
        <- decrement depth back to 0
    <- outer dispatchRoute's is_int branch returns the inner ResponseInterface
    -> bubbles up the PSR middleware stack
    -> emitted to client: 404 with body "CUSTOM-404-BODY"

Throwing 502 handler — recursion guard activates

Route returns 502
    -> dispatchRoute is_int → renderError(502)
        -> depth=0, find 502 handler, seed status=502, depth=1
        -> dispatchRoute(502_handler)
            -> handler throws RuntimeException
            -> dispatchRoute catch: ExitException? no; depth=1 > 0 → RETHROW
        <- catch in renderError's try fires
        -> log "Error handler for 502 itself threw"
        -> finally: depth=0
    <- return defaultErrorResponse(502, null)
        -> Accept: text/html → `<pre>502 Bad Gateway</pre>`
    -> emit

Concurrent error handlers — isolation

Coroutine A: handler /slow-handler-set
    -> set_error_handler($cbA) (push to G_A->error_handlers_stack)
    -> co::sleep(0.5)
                                          Coroutine B: handler /fast-trigger
                                          -> @trigger_error('from B', E_USER_WARNING)
                                          -> native dispatcher fires:
                                              $g = G::instance() -> G_B (different coroutine context)
                                              G_B->error_handlers_stack = []
                                              return false (PHP default)
                                          -> handler returns JSON {handler_fired: 0}
    -> sleep done, returns

A's handler never sees B's warning because the native dispatcher reads G::instance() — which returns the coroutine-scoped instance via OpenSwoole\Coroutine::getContext() when App::$superglobals === false.


Verification

Three integration test suites cover the surface:

  • tests/Integration/ErrorHandlingTest.php — 9 cases on setErrorHandler: status-specific 404/500/403/400/418 handlers, exception param injection, array→JSON, Generator streaming, handler-self-throws recursion guard, status-only-return routing.
  • tests/Integration/ErrorHandlersIsolationTest.php — 10 cases: warning capture, stack push/restore/pop-beyond-empty, cross-coroutine isolation (curl_multi staggered fire + Store-backed CID write), exception handler echo, shutdown function order/status/per-request/throw-survives.
  • tests/Integration/ContentNegotiationTest.php — 6 cases: HTML default, custom handler wins over Accept, per-request error_reporting, suppression by level.

Manual smoke:

curl http://localhost:8080/__error_test/throw-not-found                       # CUSTOM-404-BODY
curl -H 'Accept: application/json' http://localhost:8080/this-does-not-exist  # default 404 (HTML via fallback fixture, or JSON otherwise)
curl http://localhost:8080/__error_test/handler-self-throws                   # default 502 (recursion guard)
curl http://localhost:8080/__error_test/exception-handler-echo                # HANDLED:boom-exc
curl http://localhost:8080/__error_test/shutdown-echo                         # HANDLER-RANSHUTDOWN-RAN
curl http://localhost:8080/__error_test/shutdown-status                       # status 503

Source map

Concern File
setErrorHandler, renderError, defaultErrorResponse, REASON_PHRASES src/App.php
Process-level native handler bootstrap src/App.php __construct() (top)
set_error_handler / set_exception_handler / register_shutdown_function / error_reporting overrides src/utils.php
G state (error_handlers_stack, exception_handlers_stack, shutdown_functions, error_reporting_level, error_render_depth, error_status, error_exception) src/G.php
Exception handler integration in dispatchRoute / dispatchRawRoute catch src/App.php
Shutdown function drain src/App.php on('request') handler
Test fixture route/_error_test.php