Error handling
Every exception thrown anywhere in a request is caught in one place: ErrorHandlingMiddleware, the outermost middleware in the pipeline. Because it wraps the entire stack, there is a single, predictable path from “something threw” to “a response went out” — no scattered try/catch, no partial output. This page traces that path and shows how to shape or replace what the user sees.
How it fits in a request
Section titled “How it fits in a request”The kernel builds the pipeline by scanning #[Middleware] attributes (MiddlewareAttributeScanner) and ordering them with a topological sort (MiddlewareOrderResolver) — first by phase, then by priority, where a higher priority runs further outward. ErrorHandlingMiddleware carries #[Middleware(phase: 'bootstrap', priority: 1000)] — the highest priority in the outermost phase — so it becomes the outermost wrapper around everything else.
A request therefore travels like this:
Kernel boots and orders the pipeline → request enters
ErrorHandlingMiddleware(outermost) →TelemetryMiddleware→ … →RoutingMiddlewareresolves the action →DispatchMiddlewareruns it and renders the view → response travels back out → emitted.
If anything downstream throws, the stack unwinds back up to ErrorHandlingMiddleware’s single try/catch, which turns the throwable into a response. For the whole pipeline see The middleware pipeline and The request lifecycle.
The flow
Section titled “The flow”Because it sits at the very top of the pipeline (phase bootstrap, highest priority), ErrorHandlingMiddleware is the last thing to see the request on the way in and the first to catch anything on the way out. When a Throwable escapes the stack it:
- Logs a dense diagnostic line — exception class, message,
file:line, request method and URI, allowed methods (for a method-not-allowed error), thecaused byprevious-exception chain, and the trace. - Emits an
ExceptionCaughtEventcarrying the throwable and the request, so listeners can report the error (Sentry, Bugsnag, metrics) uniformly. See Events. - Maps the exception to an HTTP status (below).
- Picks a renderer based on
core.developer_exceptionsand returns its response.
The status mapping is intentionally small:
| Exception | Status |
|---|---|
InvalidArgumentException | 400 |
DomainException | 422 |
| anything else | 500 |
There are no dedicated HTTP exception classes (no NotFoundException / ForbiddenException). A 404 is not exception-driven — an unmatched route is handled by the routing and dispatch middleware returning a 404 response directly, not by throwing. So error handling here is about unexpected failures becoming a safe response, not about modelling HTTP status codes as exceptions.
Developer vs safe rendering
Section titled “Developer vs safe rendering”Which renderer runs is decided by a single setting, core.developer_exceptions:
return [ 'core.developer_exceptions' => false, // true only in development];core.developer_exceptions: false<!-- Config/settings.xml — inside <settings> --><setting name="core.developer_exceptions">false</setting>false(production default) selectsSafeRenderer. Never leaks the class, message, or trace. It content-negotiates onAccept: a JSON body ({error, status, correlation_id}), a plain-textInternal error, or a minimal HTML page. The message is generic —Internal Server Errorfor 5xx,Request Errorotherwise.trueselectsWhoopsRenderer. The full Whoops developer page — stack trace, source, request data — or its JSON/plain-text handlers depending onAccept. It is configured to return markup as a string rather than writing to output, so it is safe under worker mode. The Whoops renderer ships in the optionalquioteframework/whoopspackage and is opt-in — install it and registerQuiote\Exception\Rendering\Whoops\WhoopsPluginin yourplugins. Without the plugin,core.developer_exceptions = truestill uses theSafeRenderer.
Both include the request’s correlation id, so an error a user reports can be matched to the log line for it. See Logging.
The scaffolded app ships a /boom action that throws on purpose — hit it with core.developer_exceptions on and off to see the two renderers.
Customizing error output
Section titled “Customizing error output”There are two seams, depending on whether you want to change what happens or what is shown.
To react to an error — report it, increment a metric, notify — listen for ExceptionCaughtEvent. This is the clean place for error reporting because it fires for every caught throwable, before rendering:
$events->addListener(ExceptionCaughtEvent::class, function (ExceptionCaughtEvent $e) { $this->sentry->captureException($e->exception);});To change the rendered response, implement the renderer contract, Quiote\Exception\Rendering\ExceptionRenderer:
namespace App\Error;
use Psr\Http\Message\{ResponseInterface, ServerRequestInterface};use Quiote\Exception\Rendering\ExceptionRenderer;
final class BrandedErrorRenderer implements ExceptionRenderer{ public function render( \Throwable $e, ServerRequestInterface $request, int $status, ?string $correlationId, ): ResponseInterface { // build and return a real PSR-7 response }}A renderer must be worker-safe: no echo, no exit, no superglobals — build and return a PSR-7 response. You can also subclass SafeRenderer or WhoopsRenderer to tweak one behaviour rather than starting from scratch. The render() method receives the mapped status and the correlation id, so a branded 500 page can still surface the id for support.
Registering a renderer from a plugin
Section titled “Registering a renderer from a plugin”There are two slots, one per side of core.developer_exceptions, and a plugin fills either:
$registrar ->safeExceptionRenderer(static fn() => new BrandedErrorRenderer()) // production ->developerExceptionRenderer(static fn() => new MyDeveloperRenderer()); // developmentBoth are set-if-absent: the first registration wins, which is the same override rule every other plugin seam follows. Nothing registered in a slot means ErrorHandlingMiddleware falls back to SafeRenderer — including for core.developer_exceptions = true when the whoops package isn’t installed. The factory is only invoked when a renderer is actually needed, so asking whether one exists never constructs it.
The safe slot is new in 4.1; the developer slot has been there since the Whoops renderer was extracted. Core never hard-references a concrete renderer class either way — that’s what the registry exists for.
Framework exceptions
Section titled “Framework exceptions”The base type is Quiote\Exception\QuioteException (extends \Exception, and supports string error codes such as PDO SQLSTATEs). The framework throws typed subclasses for its own failure modes — among them ConfigurationException, DatabaseException, SecurityException, ValidatorException, ViewException, ClassNotFoundException, DisabledModuleException, and StorageException. All flow through the same ErrorHandlingMiddleware, so catching one in your own code is optional — anything you don’t handle becomes a safe response.