Clean controllers in Symfony (I): exception handling
Tags: php, symfony, backend, api
Published: 2023-09-27 07:03:10

## Introduction

Suppose we need to implement the following endpoint of an API:

URL: `GET /books/{uuid}`

- Valid response
    - HTTP code: `200`
    - Body:
        ```json
        {
            "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5",
            "title": "Life among the Indians",
            "author": "George Catlin"
        }
        ```

- Invalid response: book not found:
    - HTTP code: `404`
    - Body:
        ```json
        {
            "error": "BookDTO with id \"c59620eb-c0ab-4a0c-8354-5a20faf537e5\" not found"
        }
        ```

- Invalid response: UUID with invalid format:
    - HTTP code: `422`
    - Body:
        ```json
        {
            "error": "Provided bookId format \"c59620eb-c0ab-4a0c-8354-5a20faf537e5\" is not a valid UUID"
        }
        ```

In Symfony, if we use a query bus, we could have the following controller in order to implement the required endpoint:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Book\GetBookDTOByIdQuery;
use rubenrubiob\Domain\DTO\Book\BookDTO;
use rubenrubiob\Domain\Exception\Repository\Book\BookDTONotFound;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdFormatIsNotValid;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdIsEmpty;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

final readonly class GetBookController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    public function __invoke(string $bookId): Response
    {
        try {
            /** @var BookDTO $bookDTO */
            $bookDTO = $this->queryBus->__invoke(
                new GetBookDTOByIdQuery(
                    $bookId
                )
            );
        } catch (BookDTONotFound $e) {
            return new JsonResponse(
                [
                    'error' => $e->getMessage(),
                ],
                Response::HTTP_NOT_FOUND,
            );
        } catch (BookIdFormatIsNotValid|BookIdIsEmpty $e) {
            return new JsonResponse(
                [
                    'error' => $e->getMessage(),
                ],
                Response::HTTP_UNPROCESSABLE_ENTITY,
            );
        }

        return new JsonResponse(
            [
                'id' => $bookDTO->bookId->toString(),
                'title' => $bookDTO->bookTitle->toString(),
                'author' => $bookDTO->authorNanme->toString(),
            ],
            Response::HTTP_OK,
        );
    }
}

```

Even though we meet the requirements, we see that the controller has plenty of logic: we need to handle the presentation and HTTP code of both the valid response and the errors.

In this post, we will see how to delegate the handling of exceptions to the framework. This way, we will simplify our controllers and centralize the formatting of the error bodies.

## Symfony kernel events

From a backend perspective, the flow of an HTTP request consists of receiving a request and returning a response. Symfony’s [`HttpKernel`](https://symfony.com/doc/current/components/http_kernel.html) is flexible enough to accommodate this flow for all kinds of applications, from microframeworks to full CMS such as Drupal.

Symfony kernel is driven by events, so the logic of the request is done by dispatching events. This makes the flow a bit abstract, but, at the same time, it allows the developer to execute code in concrete hooks of said flow. These hooks are shown in the following image[^1]:


![Symfony kernel events table. Source: Symfony documentation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/93b238m6864o92el4jfj.png)

Therefore, we can delegate the exception handling to a hook in the kernel by using a listener or a subscriber. In our case, we chose a subscriber. To decide between both options, there is a comparison in [Symfony's documentation](https://symfony.com/doc/current/event_dispatcher.html#listeners-or-subscribers).

## `ExceptionResponseSubscriber`: first version

We can implement an `EventSubscriber` that listens to the `kernel.exception` event, the place where Symfony manages all unhandled exceptions. The implementation is:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Subscriber;

use rubenrubiob\Domain\Exception\Repository\Book\BookDTONotFound;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdFormatIsNotValid;
use rubenrubiob\Domain\Exception\ValueObject\Book\BookIdIsEmpty;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;

use function array_key_exists;

final readonly class ExceptionResponseSubscriber implements EventSubscriberInterface
{
    private const EXCEPTION_RESPONSE_HTTP_CODE_MAP = [
        BookDTONotFound::class => Response::HTTP_NOT_FOUND,
        BookIdFormatIsNotValid::class => Response::HTTP_UNPROCESSABLE_ENTITY,
        BookIdIsEmpty::class => Response::HTTP_UNPROCESSABLE_ENTITY,
    ];

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

    public function __invoke(ExceptionEvent $event): void
    {
        $throwable = $event->getThrowable();

        $response = new JsonResponse(
            [
                'error' => $throwable->getMessage(),
            ],
            $this->httpCode($throwable),
            [
                'Content-Type' => 'application/json',
            ]
        );

        $event->setResponse($response);
    }

    private function httpCode(Throwable $throwable): int
    {
        $throwableClass = $throwable::class;
        if (array_key_exists($throwableClass, self:: EXCEPTION_RESPONSE_HTTP_CODE_MAP)) {
            return self:: EXCEPTION_RESPONSE_HTTP_CODE_MAP[$throwableClass];
        }

        return Response::HTTP_INTERNAL_SERVER_ERROR;
    }
}

```

For each exception within our codebase, we add it to the constant `EXCEPTION_RESPONSE_HTTP_CODE_MAP`, where we map the exception to the HTTP that it must return. If the exception is not mapped, we return a `500` HTTP code.

We also format the error body, which in this case is pretty basic: we simply show the exception message[^2].

Depending on the body of the response, we would need to add more logic to the subscriber, for example, if we use [Problem Details](https://www.rfc-editor.org/rfc/rfc7807) or [JSON:API](https://jsonapi.org//format/#errors)[^3].

If we [autoconfigure](https://symfony.com/doc/current/service_container.html#the-autoconfigure-option) the services, the subscriber is already registered in our application.

## Simplified controller

With the subscriber in place, we can simplify the controller by deleting all the error handling.

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Ui\Http\Controller;

use rubenrubiob\Application\Query\Book\GetBookDTOByIdQuery;
use rubenrubiob\Domain\DTO\Book\BookDTO;
use rubenrubiob\Infrastructure\QueryBus\QueryBus;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

final readonly class GetBookController
{
    public function __construct(private QueryBus $queryBus)
    {
    }

    public function __invoke(string $bookId): Response
    {
        /** @var BookDTO $bookDTO */
        $bookDTO = $this->queryBus->__invoke(
            new GetBookDTOByIdQuery(
                $bookId
            )
        );

        return new JsonResponse(
            [
                'id' => $bookDTO->bookId->toString(),
                'title' => $bookDTO->bookTitle->toString(),
                'author' => $bookDTO->authorName->toString(),
            ],
            Response::HTTP_OK,
        );
    }
}

```

By delegating the exception handling to a subscriber, we simplify the logic of the controller: we only call the query bus and present the value it returns.

Nonetheless, with this implementation of the subscriber, for each new exception we add to our code that needs a concrete HTTP code, we need to add it to the `EXCEPTION_RESPONSE_HTTP_CODE_MAP`. This option does not grow well, as that `array` could be so big that it becomes unmanageable. Besides, every developer must remember to add the exception to the map.

## Exception interfaces

To solve this issue, we can use an alternative: using interfaces in our domain exceptions.

For example, in this case we can have two interfaces: one for all value object, exceptions, and the other for the not found elements.

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\ValueObject;

interface InvalidValueObject
{
}

```

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\Repository;

interface NotFound
{
}

```

Implementing these interfaces does not imply anything other than adding an `implement` to our exceptions because they do not have a body. Therefore, the `BookDTONotFound` class can look like this:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\Repository\Book;

use Exception;
use rubenrubiob\Domain\Exception\Repository\NotFound;
use rubenrubiob\Domain\ValueObject\Book\BookId;

use function sprintf;

final class BookDTONotFound extends Exception implements NotFound
{
    public static function withBookId(BookId $bookId): self
    {
        return new self(
            sprintf(
                'Book with bookId "%s" not found',
                $bookId->toString(),
            )
        );
    }
}

```

## `ExceptionResponseSubscriber`: second version

With the interfaces implemented in our domain exceptions, we can then refactor the `ExceptionResponseSubscriber`:

```php
<?php

declare(strict_types=1);

namespace rubenrubiob\Infrastructure\Symfony\Subscriber;

use rubenrubiob\Domain\Exception\Repository\NotFound;
use rubenrubiob\Domain\Exception\ValueObject\InvalidValueObject;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;

use function array_key_exists;
use function Safe\class_implements;

final readonly class ExceptionResponseSubscriber implements EventSubscriberInterface
{
    private const EXCEPTION_RESPONSE_HTTP_CODE_MAP = [
        NotFound::class => Response::HTTP_NOT_FOUND,
        InvalidValueObject::class => Response::HTTP_UNPROCESSABLE_ENTITY,
    ];

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

    public function __invoke(ExceptionEvent $event): void
    {
        $throwable = $event->getThrowable();

        $response = new JsonResponse(
            [
                'error' => $throwable->getMessage(),
            ],
            $this->httpCode($throwable),
            [
                'Content-Type' => 'application/json',
            ]
        );

        $event->setResponse($response);
    }

    private function httpCode(Throwable $throwable): int
    {
        /** @var class-string[] $interfaces */
        $interfaces = class_implements($throwable);

        foreach ($interfaces as $interface) {
            if (array_key_exists($interface, self::RESPONSE_CODE_MAP)) {
                return self::EXCEPTION_RESPONSE_HTTP_CODE_MAP[$interface];
            }
        }

        return Response::HTTP_INTERNAL_SERVER_ERROR;
    }
}

```

With only these two interfaces, we avoid having to add each exception that we use in our code. Any exception thrown in a value object or a not found, it will return the correct HTTP code with any other change.

## Conclusions

Using interfaces is not a definitive solution, because there could be situations where we want to map a concrete exception to an HTTP code. In any case, it is possible to combine both options, though it would be better to refactor this logic into a service.

As always, the best way is to evolve the code depending on the needs we find along the way.

## Summary

- We saw the potential of Symfony's event-driven kernel to simplify our applications.
- We refactored a controller to extract the error handling and delegate it to a subscriber.
- We implemented a subscriber for the `kernel.exception` to handle the exceptions of an API in a centralized way.


[^1]: There is a table with all the kernel events and its parameters in [Symfony's official documentation](https://symfony.com/doc/current/components/http_kernel.html#component-http-kernel-event-table).

[^2]: Showing the exception message in an exception is not usually a good practice because they tend to be messages written by and for developers. Besides, they could contain sensible data that must not be exposed.

[^3]: Check [this post by "APIs you won't hate"](https://apisyouwonthate.com/blog/useful-api-errors-for-rest-graphql-and-grpc/) to learn about different alternatives.