Vortos
HTTP

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 it

When 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 on message
  • message — 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');       // 403

These 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:

  • DomainError messages 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 TypeStatusLog Level
DomainError5xxCRITICAL
DomainError4xxERROR
HttpExceptionInterface5xxCRITICAL
HttpExceptionInterface4xxERROR
Any other exceptionCRITICAL

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 {}

On this page