Skip to content

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.

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 → … → RoutingMiddleware resolves the action → DispatchMiddleware runs 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.

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:

  1. Logs a dense diagnostic line — exception class, message, file:line, request method and URI, allowed methods (for a method-not-allowed error), the caused by previous-exception chain, and the trace.
  2. Emits an ExceptionCaughtEvent carrying the throwable and the request, so listeners can report the error (Sentry, Bugsnag, metrics) uniformly. See Events.
  3. Maps the exception to an HTTP status (below).
  4. Picks a renderer based on core.developer_exceptions and returns its response.

The status mapping is intentionally small:

ExceptionStatus
InvalidArgumentException400
DomainException422
anything else500

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.

Which renderer runs is decided by a single setting, core.developer_exceptions:

Config/settings.php
return [
'core.developer_exceptions' => false, // true only in development
];
  • false (production default) selects SafeRenderer. Never leaks the class, message, or trace. It content-negotiates on Accept: a JSON body ({error, status, correlation_id}), a plain-text Internal error, or a minimal HTML page. The message is generic — Internal Server Error for 5xx, Request Error otherwise.
  • true selects WhoopsRenderer. The full Whoops developer page — stack trace, source, request data — or its JSON/plain-text handlers depending on Accept. 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 optional quioteframework/whoops package and is opt-in — install it and register Quiote\Exception\Rendering\Whoops\WhoopsPlugin in your plugins. Without the plugin, core.developer_exceptions = true still uses the SafeRenderer.

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.

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.

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

Both 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.

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.