letkode / http-exception-bundle
HTTP status exceptions and a JSON exception listener for Symfony applications
Package info
github.com/letkode/http-exception-bundle
Type:symfony-bundle
pkg:composer/letkode/http-exception-bundle
Requires
- php: ^8.4
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/config: ^7.0 || ^8.0
- symfony/dependency-injection: ^7.0 || ^8.0
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/http-kernel: ^7.0 || ^8.0
- symfony/translation-contracts: ^3.0
- symfony/yaml: ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2
- phpunit/phpunit: ^11
- symfony/validator: ^7.0 || ^8.0
Suggests
- symfony/translation: Required by the framework to provide the TranslatorInterface service the listener autowires
- symfony/validator: To render 422 responses with per-field errors from ValidationFailedException
Provides
None
Conflicts
None
Replaces
None
README
HTTP status exceptions and a JSON exception listener for Symfony applications.
Installation
composer require letkode/http-exception-bundle
Symfony Flex registers the bundle automatically. Otherwise:
// config/bundles.php return [ Letkode\HttpExceptionBundle\LetkodeHttpExceptionBundle::class => ['all' => true], ];
Requires a configured Symfony translator and a PSR-3 logger. Install symfony/validator to get per-field errors on 422 responses.
Configuration
All options are optional; defaults are shown.
# config/packages/letkode_http_exception.yaml letkode_http_exception: path_prefix: /api # only requests whose path starts with this are handled listener_enabled: true # set to false to not register the ExceptionListener listener_priority: 0 # priority of the kernel.exception listener
path_prefix is matched as a plain string prefix, so /api also matches /apiary. An empty string ('') makes the listener handle every path.
Debug traces follow %kernel.debug%; there is nothing to configure.
Using your own exception listener
To handle exceptions with your own listener, turn the bundle's off:
letkode_http_exception: listener_enabled: false
The exceptions, the contracts, TranslationOption and the locale resolver stay available; only the kernel.exception listener is not registered. Your listener can keep relying on HttpStatusExceptionInterface (getStatusCode(), getErrorCode(), getOption()).
Usage
Throw an exception from any service; the listener turns it into JSON.
use Letkode\HttpExceptionBundle\Exception\EntityNotFoundException; throw new EntityNotFoundException('User not found.'); throw new EntityNotFoundException('User not found.', 'USER_NOT_FOUND'); // custom errorCode
{ "success": false, "message": "User not found.", "status": 404, "errorCode": "USER_NOT_FOUND" }
Every exception has a default errorCode (for example BAD_REQUEST), overridable through the second constructor argument.
Exceptions
| Class | HTTP | Default errorCode |
|---|---|---|
BadRequestException |
400 | BAD_REQUEST |
UnauthorizedException |
401 | UNAUTHORIZED |
ForbiddenException |
403 | FORBIDDEN |
NotFoundException |
404 | NOT_FOUND |
EntityNotFoundException |
404 | ENTITY_NOT_FOUND |
MethodNotAllowedException |
405 | METHOD_NOT_ALLOWED |
NotAcceptableException |
406 | NOT_ACCEPTABLE |
ConflictException |
409 | CONFLICT |
GoneException |
410 | GONE |
PreconditionFailedException |
412 | PRECONDITION_FAILED |
PayloadTooLargeException |
413 | PAYLOAD_TOO_LARGE |
UnsupportedMediaTypeException |
415 | UNSUPPORTED_MEDIA_TYPE |
UnprocessableEntityException |
422 | UNPROCESSABLE_ENTITY |
LockedException |
423 | LOCKED |
PreconditionRequiredException |
428 | PRECONDITION_REQUIRED |
TooManyRequestsException |
429 | TOO_MANY_REQUESTS |
InternalServerErrorException |
500 | INTERNAL_SERVER_ERROR |
NotImplementedException |
501 | NOT_IMPLEMENTED |
BadGatewayException |
502 | BAD_GATEWAY |
ServiceUnavailableException |
503 | SERVICE_UNAVAILABLE |
GatewayTimeoutException |
504 | GATEWAY_TIMEOUT |
Server errors (5xx) are logged as critical.
Translated messages
Pass a TranslationOption to translate the message (which then acts as a translation key):
use Letkode\HttpExceptionBundle\Exception\BadRequestException; use Letkode\HttpExceptionBundle\Option\TranslationOption; throw new BadRequestException( 'errors.invalid_range', options: [new TranslationOption(domain: 'exceptions', parameters: ['%max%' => 10])], );
Without a TranslationOption the message is returned as is.
Extending the exceptions
The exceptions are not final. Create a subclass for a domain case and override defaultErrorCode() to give it its own code:
use Letkode\HttpExceptionBundle\Exception\EntityNotFoundException; class UserNotFoundException extends EntityNotFoundException { protected function defaultErrorCode(): string { return 'USER_NOT_FOUND'; } }
Anything that catches EntityNotFoundException (or renders it) also handles the subclass. Your own exception class can also extend AbstractHttpStatusException (implement getStatusCode() and defaultErrorCode()) or implement HttpStatusExceptionInterface.
Errors by field
Attach an ErrorsOption to include an errors object in the response, keyed by field. Each message is a string or a Symfony TranslatableInterface, which is translated with the request locale:
use Letkode\HttpExceptionBundle\Exception\UnprocessableEntityException; use Letkode\HttpExceptionBundle\Option\ErrorsOption; throw new UnprocessableEntityException( 'Invalid input.', 'INVALID_INPUT', options: [new ErrorsOption(['name' => ['Required.'], 'items[0].sku' => [new MyTranslatableMessage()]])], );
{ "success": false, "message": "Invalid input.", "status": 422, "errorCode": "INVALID_INPUT", "errors": { "name": ["Required."], "items[0].sku": ["..."] } }
Without the option the response has no errors key.
Other exceptions
| Thrown | Response |
|---|---|
UnprocessableEntityHttpException wrapping a ValidationFailedException |
422, errors grouped by field |
Any Symfony HttpExceptionInterface |
its status; framework messages replaced by http.<status> / http.default; traces in debug |
| Anything else | 500 with the http.500 message, details only in the log |
Translations
The bundle ships exceptions.en.yaml and exceptions.es.yaml (keys http.<status>, http.default, validation.failed). Override any key by defining it in your application's translations/exceptions.<locale>.yaml.
Locale
Messages are translated with the locale of the current request. To change that, implement Letkode\HttpExceptionBundle\Contract\LocaleResolverInterface and alias it in your services.yaml:
Letkode\HttpExceptionBundle\Contract\LocaleResolverInterface: '@App\Locale\MyLocaleResolver'
License
MIT