Error Handling
ErrorController — DomainError mapping, JSON vs HTML responses, debug vs production mode, log levels.
Error Handling
ErrorController handles all uncaught exceptions. It decides the response format (JSON or HTML), the message to expose, and the log level based on the exception type and request headers.
Domain Errors
The primary way to signal a named business failure is to throw a DomainError. ErrorController handles these before any other exception type.
// Domain layer — declare the error
#[HttpStatus(404)]
final class UserNotFoundError extends DomainError
{
public static function forId(string $id): self
{
return new self("User '{$id}' was not found.", context: ['userId' => $id]);
}
public function errorCode(): string { return 'USER_NOT_FOUND'; }
}
// Application layer — throw or return Result
throw UserNotFoundError::forId($id);
// HTTP layer — no extra handling needed; ErrorController catches itWhen a DomainError reaches ErrorController, the JSON response is:
{
"error": true,
"code": "USER_NOT_FOUND",
"message": "User '123' was not found.",
"context": {
"userId": "123"
}
}code— the stable, machine-readable error code. Clients branch on this, not onmessagemessage— human-readable, always shown (domain errors are safe to expose by design)context— structured key/value pairs for UI or client SDK use
The HTTP status code is read from #[HttpStatus] on the error class via reflection — result cached per class. If the attribute is absent, defaults to 422.
See Domain Errors & Result for the full guide on defining and using domain errors.
HTTP Exceptions
For infrastructure-level HTTP failures (routing, auth, method not allowed), Vortos provides typed HTTP exceptions:
use Vortos\Http\Exception\NotFoundException;
use Vortos\Http\Exception\UnauthorizedException;
use Vortos\Http\Exception\ForbiddenException;
use Vortos\Http\Exception\BadRequestException;
throw new NotFoundException('Resource not found'); // 404
throw new UnauthorizedException('Login required'); // 401
throw new ForbiddenException('Access denied'); // 403These all implement HttpExceptionInterface and return their specific status code.
HTTP exceptions vs domain errors
Use NotFoundException when a route or resource literally does not exist at the HTTP layer (e.g., an unknown route). Use UserNotFoundError (a DomainError) when a user is looked up by ID and does not exist — that is a business outcome, not an HTTP infrastructure problem.
JSON vs HTML
The response format is determined by the request headers:
private function wantsJson(Request $request): bool
{
return $request->headers->get('Content-Type') === 'application/json'
|| $request->headers->get('Accept') === 'application/json';
}API clients that send Content-Type: application/json or Accept: application/json receive JSON. Browser clients receive an HTML error page.
Debug vs Production
Enable debug mode in your bootstrap:
$container->setParameter('kernel.debug', $_ENV['APP_ENV'] === 'dev');In debug mode:
- Stack trace included in JSON responses as
"trace": [...] - Code snippet shown in HTML error page (10 lines around the exception line)
- All exception messages shown regardless of type
In production mode:
DomainErrormessages are always shown (safe by design)- HTTP 4xx exception messages are shown
- All other exceptions show
"Something went wrong, please try again later." - No stack trace exposed
Log Levels
ErrorController logs exceptions at different levels based on resolved HTTP status:
| Exception Type | Status | Log Level |
|---|---|---|
DomainError | 5xx | CRITICAL |
DomainError | 4xx | ERROR |
HttpExceptionInterface | 5xx | CRITICAL |
HttpExceptionInterface | 4xx | ERROR |
| Any other exception | — | CRITICAL |
4xx errors are expected — clients made bad requests. 5xx errors require attention.
Custom Error Controller
Replace ErrorController with your own by registering it in config/services.php:
$services->set(ErrorController::class, MyErrorController::class)
->arg('$debug', '%kernel.debug%')
->arg('$logger', service(LoggerInterface::class))
->public();Your controller must implement ExceptionHandlerInterface:
interface ExceptionHandlerInterface
{
public function handle(\Throwable $e, Request $request): ?Response;
}PublicExceptionInterface
PublicExceptionInterface is a marker interface on non-domain, non-HTTP exceptions that tells ErrorController it is safe to show their message in production. It exists for edge cases where a plain exception needs to surface its message — prefer DomainError for all new code.
use Vortos\Http\Contract\PublicExceptionInterface;
final class LegacyException extends \RuntimeException implements PublicExceptionInterface {}