Managing api responses for exceptions on Symfony using KernelEvents

Nacho Colomina
3 min readJan 3, 2023

--

Many times, when building an api, we need to return diferent responses to clients depending on exeption thrown.

In this short article, I would like to show you how to achieve this by using Symfony Kernel Exception event.

Let’s imagine we are building a json api which throws (among others) the following exceptions:

  • Symfony\Component\Validator\Exception\ValidationFailedException: When input data validation fails.
  • Symfony\Component\HttpClient\Exception\TransportException: When a call to an external resource fails.

And let’s assume we want to:

  • When a ValidationFailedException is thrown, return the error list to the client and a 400 Http bad request response code
  • When a TransportException is thrown, return a simple message reporting that an external resource is not available, and a 400 Http bad request response code too.

Let’s start by creating the service which manages the behaviour for each exception.

class ExceptionResponseBuilder
{

public function __construct(
private readonly ValidationFailedErrorsBuilder $validationFailedErrorsBuilder
) { }

public function getExceptionResponse(mixed $exception): ?JsonResponse
{
return match (get_class($exception)) {
ValidationFailedException::class => $this->getResponseForValidationFailedException($exception),
TransportExceptionInterface::class => $this->getResponseForTransportException($exception),
default => null
};
}

private function getResponseForValidationFailedException(ValidationFailedException $exception): JsonResponse
{
$errors = $this->validationFailedErrorsBuilder->build($exception->getViolations());
return new JsonResponse(['error' => 'INPUT_DATA_ERRORS', 'data' => $errors], Response::HTTP_BAD_REQUEST);
}

private function getResponseForTransportException(TransportExceptionInterface $exception): JsonResponse
{
return new JsonResponse(['error' => 'EXTERNAL_RESOURCE_UNAVAILABLE', 'data' => []], Response::HTTP_BAD_REQUEST);
}
}

getExceptionResponse method gets the exception thrown as a parameter, matches its class name with the exceptions we want to manage and returns the JsonResponse for the matching one or null if no match found.

Method getResponseForValidationFailedException, relies on service ValidationFailedErrorsBuilder to build an errors array from Symfony validation ConstraintViolationList class. Let’s see how it looks:

class ValidationFailedErrorsBuilder
{
public function build(ConstraintViolationListInterface $list): array
{
$errors = [];
foreach ($list as $violation){
$errors[$violation->getPropertyPath()] = $violation->getMessage();
}

return $errors;
}
}

As we can see, it loops violations and builds an associative array where keys are error paths (in a form will be field names) and error messages are values.

Now, let’s create an event subscriber where we will keep listening to KernelEvents::EXCEPTION event

class KernelSubscriber implements EventSubscriberInterface
{

public function __construct(
private readonly ExceptionResponseBuilder $exceptionResponseBuilder
) { }

public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException']
];
}

public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
$response = $this->exceptionResponseBuilder->getExceptionResponse($exception);
if($response){
$event->setResponse($response);
}
}
}

When an exception is thrown and onKernelException method executed, it uses our ExceptionResponseBuilder service to get the JsonResponse according to the exception class. If a JsonResponse is returned it will be setted to the event

Now, Let’s test our exception response builder so that we can ensure it behaves as expected

In order to build and execute tests, I’ve been used symfony phpunit pack. You can learn more about it here: https://symfony.com/doc/current/testing.html

class ExceptionResponseBuilderTest extends KernelTestCase
{
public function testTransportExceptionResponse(): void
{
$exceptionResponseBuilder = static::getContainer()->get('App\Exception\ExceptionResponseBuilder');

$response = $exceptionResponseBuilder->getExceptionResponse(new TransportException('Resource unavailable'));
$this->assertInstanceOf(JsonResponse::class, $response);

$content = json_decode($response->getContent(), true);
$this->assertEquals('EXTERNAL_RESOURCE_UNAVAILABLE', $content['error']);
}

public function testValidationExceptionResponse(): void
{
$exceptionResponseBuilder = static::getContainer()->get('App\Exception\ExceptionResponseBuilder');

$validationFailedException = new ValidationFailedException(
null,
new ConstraintViolationList(
[
new ConstraintViolation('Name invalid', null, [], null, 'name', '658v')
]
)
);

$response = $exceptionResponseBuilder->getExceptionResponse($validationFailedException);
$this->assertInstanceOf(JsonResponse::class, $response);

$content = json_decode($response->getContent(), true);
$this->assertEquals('INPUT_DATA_ERRORS', $content['error']);
$this->assertNotEmpty($content['data']);
}

public function testNoMappedException(): void
{
$exceptionResponseBuilder = static::getContainer()->get('App\Exception\ExceptionResponseBuilder');
$response = $exceptionResponseBuilder->getExceptionResponse(new \Exception('error'));
$this->assertNull($response);
}
}

As we can see in the class, we’ve tested each case of ExceptionResponseBuilder match to ensure all cases work as expected. After executing the tests we can see a success output

Tests execution result

In my last published book, I use KernelEvents to centralize the exception management and handle them depending of the kind of exception. If you want to know more, you can find the book here: Building an operation-oriented API using PHP and the Symfony Framework

--

--