Errors and debugging
A fixed answer for the client, the details for you.
When an exception escapes a controller or a middleware, HttpKernel turns it into a response. The client gets a short, fixed answer. The exception message and trace never reach it: they are for your logs. This page starts with the configuration that is safe to deploy, then covers each piece.
A safe production setup
In production, leave debug off and report errors through an event listener. The environment decides, and the safe value is the default:
application:
environment: !env APP_ENV, production
debug: !env APP_DEBUG, false
use Zephyrus\Core\ApplicationBuilder;
use Zephyrus\Core\ExceptionEvent;
use Zephyrus\Event\EventDispatcher;
$events = new EventDispatcher();
$events->addListener(ExceptionEvent::class, function (ExceptionEvent $event): void {
if (!$event->isRoutingFailure()) { // skip 404 and 405
error_log($event->getException()::class . ': ' . $event->getException()->getMessage());
}
});
$app = ApplicationBuilder::create()
->withConfiguration($config, basePath: $root)
->withRouter($router)
->withEventDispatcher($events)
->build();
With this setup an exception that no handler answers becomes a 500 with a fixed body, and the framework writes it, with its trace, to the error log (Zephyrus: unhandled exception, answered with a 500: ...) besides the line your listener adds. Nothing else is needed for a configuration that declares no security: settings (when it does, the matching middlewares must be mounted: see Security), apart from the PHP settings below. The exception handlers below only change what the client sees.
build() turns display_errors and display_startup_errors off, unless debug is on for that boot and the client is one the debugger admits (a command-line process always is). A failure before build() runs, such as a configuration typo, is still printed with its trace and vendor path when PHP's display_errors is on, and PHP's built-in default is on when no php.ini is loaded. Production still needs display_errors=Off, display_startup_errors=Off and log_errors=On: see PHP settings for production.
Without an event dispatcher, a failure reaches nobody: the client gets the fixed answer and the message is dropped. Register the listener.
What the client receives
The built-in answers carry a fixed message, never the exception's:
| Cause | Status | Notes |
|---|---|---|
| No route matched, or a route parameter does not cast to its type | 404 | Not Found |
| A route exists for another method | 405 | Method Not Allowed, with an Allow header |
A ValidationException |
422 | the field errors, in the JSON formats only |
| Anything else | 500 | Internal Server Error |
HttpExceptionResponder picks the format from the Accept header. A plain request, a browser (text/html) and */* get text/plain. A request that accepts application/json gets an envelope built by HttpErrorPayload, and one that prefers application/problem+json gets a problem document:
GET /nope (Accept: text/html)
404 Not Found
GET /nope (Accept: application/json)
404 {"error":{"status":404,"message":"Not Found"}}
GET /nope (Accept: application/problem+json)
404 {"type":"about:blank","title":"Not Found","status":404}
GET /invalid (Accept: application/json)
422 {"errors":{"email":["Enter a valid email address."]}}
The built-in answers are never HTML pages. A page that matches your layout is yours to write, below.
Your own error pages
withExceptionHandler() maps an exception class to a response. The handler receives the throwable and the request, and returns a Response, or null to decline and keep the built-in answer.
use Zephyrus\Routing\Exception\RouteNotFoundException;
use Zephyrus\Routing\Exception\RouteParameterException;
$notFound = fn (\Throwable $e, Request $request): Response
=> Response::html('<h1>Page not found</h1>', 404);
$builder = $builder
->withExceptionHandler(RouteNotFoundException::class, $notFound)
->withExceptionHandler(RouteParameterException::class, $notFound);
When several handlers match, the most specific class wins. A handler on \Throwable is a catch-all, and it also receives the exceptions the framework maps itself (ValidationException, MethodNotAllowedException, RouteNotFoundException and RouteParameterException). Decline for those, or your page replaces their 422, 405 and 404:
use Zephyrus\Routing\Exception\MethodNotAllowedException;
use Zephyrus\Routing\Exception\RouteNotFoundException;
use Zephyrus\Routing\Exception\RouteParameterException;
use Zephyrus\Validation\ValidationException;
$builder = $builder->withExceptionHandler(
\Throwable::class,
function (\Throwable $e, Request $request): ?Response {
if (
$e instanceof ValidationException
|| $e instanceof MethodNotAllowedException
|| $e instanceof RouteNotFoundException
|| $e instanceof RouteParameterException
) {
return null;
}
error_log($e::class . ': ' . $e->getMessage());
return Response::html('<h1>Something went wrong</h1>', 500);
},
);
The page is fixed text. Never put $e->getMessage() or a trace in the body: a message can hold a host name, a SQL fragment or a credential. A handler does not see the attributes that route middlewares set on the request.
A handler that itself throws produces the fixed 500 text, and the failure is reported as an ExceptionEvent. Both the handler's exception and the one it was answering are written to the error log. A handler on a client-visible page should also look at Accept if the same route serves JSON clients:
$wantsJson = str_contains($request->headers()->get('accept') ?? '', 'json');
Listening to errors
The kernel fires an ExceptionEvent each time it converts a throwable, which makes it the one place to report errors. A middleware that catches, reports and rethrows never sees them: the conversion happens inside the global pipeline, so $next() returns the error response instead of throwing (unless the exception is rethrown for the debugger, see Debugging in development).
| Method | Returns |
|---|---|
getException() |
the throwable being converted |
getRequest() |
the request |
getSource() |
where it came from: routing, handler, middleware or responder |
isRoutingFailure() |
true for a 404 or 405 |
The sources are constants on the class: ExceptionEvent::SOURCE_ROUTING (no route matched), SOURCE_HANDLER (the matched action or a route middleware threw), SOURCE_MIDDLEWARE (a global middleware threw) and SOURCE_RESPONDER (an exception handler or the responder itself threw).
$events->addListener(ExceptionEvent::class, function (ExceptionEvent $event): void {
error_log(json_encode([
'exception' => $event->getException()::class,
'path' => $event->getRequest()->path(),
'source' => $event->getSource(),
], JSON_THROW_ON_ERROR));
});
The event is observation only: a listener cannot replace the response. A listener that throws is logged (an ExceptionEvent listener failed and was skipped) and the remaining listeners still run, so a broken tracker never turns an error into a blank page. See Container and events for the dispatcher.
Debugging in development
With application.debug: true in a development or testing environment, build() enables the Tracy debugger. Without an allowlist, Tracy admits a client only under PHP's built-in server (php -S), and only a loopback one whose request carries no X-Forwarded-For, Forwarded or X-Real-IP header; under PHP-FPM or any other server API nobody is admitted, so a developer behind FPM must declare an allowlist to see the page. Version 0.14.1 admitted a loopback client under every server API. A declared allowlist (below) admits only its own entries, loopback included. Tracy recognises a proxy only by those headers, so debug must stay off unless every proxy in front of PHP sends one (nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;). A remote client behind a proxy on the same host that adds none of them (a bare proxy_pass, a TLS tunnel) looks like a local one and gets the full error page, under php -S with no allowlist or, under any server API, with a bare loopback entry: list <secret>@127.0.0.1 and send the cookie instead. The page includes stack-trace arguments and the environment, so it must never reach every client.
With debug on, an exception that no handler answers (or that a handler rethrows) shows Tracy's page to a client the debugger admits. Any other client gets the generic 500, and the exception goes to the error log. On the command line with debug on (tests, scripts), it leaves Application::handle() instead. KernelBuilder::withUnhandledExceptionsRethrown() forces that whatever the debug flag; outside a test, the exception then reaches PHP's own handler. A rethrown exception passes through the global middlewares as an exception, not as an error response.
To get Tracy's page for an error that a catch-all handler would otherwise answer, let the exception out of that handler in development only:
$debug = $builder->isDebugEnabledForBoot();
$builder = $builder->withExceptionHandler(
\Throwable::class,
function (\Throwable $e, Request $request) use ($debug): ?Response {
if ($debug) {
throw $e; // the same object: the kernel passes it on, so Tracy can show it
}
error_log($e::class . ': ' . $e->getMessage());
return Response::html('<h1>Something went wrong</h1>', 500);
},
);
Take $debug from isDebugEnabledForBoot() on the fully configured builder (after withConfiguration()). Never take it from $config->application->debug: in production that value can be true while debug is actually off, and the handler would then rethrow and leave your client with the generic 500 instead of your page. In production this handler answers with the fixed page above and never rethrows.
Production forces debug off
In production and staging, build() turns debug off whatever the file says, and logs why:
Zephyrus: application.debug is true while application.environment is production-like. Debug output has been FORCED OFF for this boot: ...
An unrecognized environment name (prodution) is production too, so a typo fails safe. To debug a live tier on purpose, call withProductionDebugAcknowledged() in your bootstrap code. It is code on purpose, not a configuration key: a configuration value is what gets flipped on a server and forgotten. Even then, the clients below (or loopback under php -S, when there is no allowlist) are the only ones meant to see anything, provided the proxy rule above holds.
$builder = $builder->withProductionDebugAcknowledged();
The client allowlist
Behind Docker, the browser reaches PHP from the bridge gateway, not from loopback. Name that client, with a secret that you also send in a tracy-debug cookie. Loopback is no longer implied once the allowlist exists, so list it too if you want it:
// DEBUG_CLIENTS holds "<secret>@172.17.0.1", <secret> from bin2hex(random_bytes(16)); unset or empty means no allowlist, so loopback only.
$builder = $builder->withDebugClientAllowlist(env('DEBUG_CLIENTS'));
An entry is an exact address, or <secret>@address, which also requires the cookie tracy-debug=<secret>. A bare loopback entry (127.0.0.1, ::1) admits no request that carries X-Forwarded-For, Forwarded or X-Real-IP: behind a proxy on the same host, list <secret>@127.0.0.1 and send the cookie. Version 0.14.0 admitted loopback whatever the allowlist said. A string may list several entries separated by commas or spaces, or you can pass an array with one entry per element. Entries are checked when you register them:
| Entry | Refusal |
|---|---|
@172.17.0.1 |
Debug client allowlist entry 1 has an empty secret before "@": with no tracy-debug cookie it would admit every client at that address. Set a secret, or list the bare address if that is intended. |
<secret>@ |
Debug client allowlist entry 1 has no address after "@". |
10.0.0.0/8 |
Debug client allowlist entry 1 is a range: Tracy matches exact addresses only, so list each address. |
array element 'a b' |
Debug client allowlist entry 1 holds whitespace or a comma: pass each address as its own array entry. |
A bare address with no secret admits anyone who can reach PHP from it, so prefer <secret>@address, with a secret of 16 random characters or more, such as bin2hex(random_bytes(16)). The messages name the position of the entry and never repeat the secret.
The secret is a credential. Keep it in the environment, never in git. The framework keeps it, and the other secrets it knows about, out of the debugger's output: the allowlist property of the builder and the tracy-debug cookie are masked in dumps and traces, along with password, token, secret, apiKey, authorization and cookie keys, the encryption key, the session cookie, the raw request body and the Set-Cookie lines of a response (Response::$cookies). DebugIntegration::SENSITIVE_KEYS lists the exact key names, SENSITIVE_PROPERTIES the masked properties, and isSensitiveKey() tests a key. The Bluescreen also masks by pattern, but only string values: a numeric secret still needs to be added to Tracy's keysToHide by name.
Good to know
- Enabling the debugger stores real argument values in every exception trace of the process, including the ones that only go to a log. Keep debug off wherever a log leaves the server.
Debugger::enable()is global and cannot be undone for the rest of the process. That is whyisDebugEnabledForBoot()reports the decision without enabling anything.- Tracy is initialized by
build(). You do not callDebugIntegration::initialize()yourself.
The exception classes
Every framework refusal is an exception whose message tells you what to fix. Read it as an instruction: it names the section, the key, the class or the argument, and says what to change.
| Class | Raised for |
|---|---|
ZephyrusException |
the base class of everything below |
ZephyrusRuntimeException |
the base of failures at run time |
ConfigurationException |
an invalid configuration, or an unwired security setting, at boot |
ContainerException, NotFoundException |
an entry the container cannot resolve |
RouteNotFoundException, RouteParameterException, MethodNotAllowedException |
a request that matches no route (404, 404, 405) |
RouteSignatureException, RouteAttributeException, RouteMiddlewareException |
a route declared wrongly, found at boot |
ValidationException |
a failed validation (422), carrying its ErrorBag |
SessionException, DatabaseException, MailerException, UploadException, RenderException, CryptographyException, LocalizationException, FormatterException, FileSystemException |
the matching module |
Catch ZephyrusException to handle anything the framework throws on purpose:
use Zephyrus\Core\Config\Configuration;
use Zephyrus\Exceptions\ZephyrusException;
try {
$config = Configuration::fromYamlFile($root . '/config.yml');
} catch (ZephyrusException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
A boot error is addressed to you, and belongs on the console or in the log. It reaches a page only when PHP's display_errors is on, which is why production sets it to Off (see above). The messages are stable enough to search for. These are the ones you will meet first:
Configuration key 'sesion' is not a known section: did you mean "session"? ...
Configuration section 'security' field 'max_bdy_size' is an unknown key: did you mean "max_body_size"?
Configuration declares security settings that this application does not enforce as declared: ...
The first two are covered in Configuration, the third in Security. Messages that quote a value (an environment name, a class) escape control characters, so a log line cannot be forged through them.
Good to know
- An error in a global middleware is converted without re-entering the middleware pipeline, so that response carries no global headers. A handler error is converted inside it, and does carry them.
withExceptionHandler()for the same class twice replaces the first handler.