Error Rendering#
Webrick now treats framework-owned HTTP failures as exceptions first, then converts them into a final Response at the kernel boundary.
That gives you two clean layers:
middleware, matchers, and routing can reject with exceptions
the top-level
ErrorHandlerdecides how the final HTTP body, headers, and status are rendered
Controllers and user middleware can still return Response objects with explicit status codes directly. This guide is only about framework failures and custom boundary rendering.
What throws now#
Common framework paths that now throw typed HTTP exceptions:
signed URL verification
throttling
request limits
maintenance mode
invalid Host handling
unacceptable content negotiation
route
404/405
The final status still becomes 400, 403, 404, 405, 406, 410, 429, 503, and so on, but only when the kernel renders the exception.
Default behavior#
If you do nothing, RouterKernel uses ErrorHandler with built-in rendering.
use Infocyph\Webrick\Router\Kernel\RouterKernel;
$kernel = RouterKernel::bootWithRegistrar(
// ...
);
Default rendering behavior:
resolves HTTP status from
HttpExceptionInterfacepreserves exception headers like
AlloworRetry-Afterpreserves public framework messages
negotiates the final response type at the boundary
Custom boundary renderer#
Pass your own ErrorHandler into RouterKernel::bootWithRegistrar(...) when you want custom output.
use Infocyph\Webrick\Exceptions\HttpExceptionInterface;
use Infocyph\Webrick\Request\Request;
use Infocyph\Webrick\Response\Response;
use Infocyph\Webrick\Router\Kernel\ErrorHandler;
use Infocyph\Webrick\Router\Kernel\RouterKernel;
use Psr\Log\NullLogger;
use Throwable;
$errorHandler = new ErrorHandler(
logger: new NullLogger(),
debug: false,
capturePhpErrors: true,
requestIdHeader: 'X-Request-Id',
responseRenderer: static function (Request $request, Throwable $e, int $status, array $headers): ?Response {
if (!str_starts_with($request->getUri()->getPath(), '/api/')) {
return null;
}
$message = $e instanceof HttpExceptionInterface
? $e->getPublicMessage()
: 'HTTP Error';
return Response::json([
'error' => $message,
'status' => $status,
'path' => $request->getUri()->getPath(),
], $status, $headers);
},
);
$kernel = RouterKernel::bootWithRegistrar(
log: new NullLogger(),
matcher: $matcher,
register: $register,
errorHandler: $errorHandler,
);
Important behavior:
return a
Responseto override the built-in rendererreturn
nullto fall back to the default rendererexception headers passed into the callback should usually be preserved
Example: API-only JSON errors#
The demo app now includes exactly this pattern:
index.phptests/IntegrationBootstrap.phproute:
/api/error-demo
The route throws:
use Infocyph\Webrick\Exceptions\HttpException;
throw HttpException::forbidden('API token missing');
The custom boundary renderer converts that into JSON for /api/* requests:
{
"error": "API token missing",
"status": 403,
"path": "/api/error-demo"
}
When to throw vs when to return#
Use exceptions for framework-owned rejection paths:
invalid signed link
too many requests
maintenance mode
malformed request/host
Return Response directly for normal application flow:
successful responses
redirects
validation responses chosen by your controller
custom business responses where the controller owns the outcome
That keeps the boundary clean:
framework rejections are normalized centrally
application responses remain explicit
Recommendations#
Keep exception messages public only when they are safe to expose
Preserve headers like
Retry-After,Allow, and cache directivesScope custom renderers by path or content type instead of forcing one format globally
For APIs, prefer returning
nullfor non-API paths so browser-facing HTML errors still use the default renderer