DEV Community

Cover image for Clean controllers in Symfony (I): exception handling
Rubén Rubio
Rubén Rubio

Posted on

Clean controllers in Symfony (I): exception handling

Introduction

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

URL: GET /books/{uuid}

  • Valid response

    • HTTP code: 200
    • Body:

      {
          "id": "c59620eb-c0ab-4a0c-8354-5a20faf537e5",
          "title": "Life among the Indians",
          "author": "George Catlin"
      }
      
  • Invalid response: book not found:

    • HTTP code: 404
    • Body:

      {
          "error": "BookDTO with id \"c59620eb-c0ab-4a0c-8354-5a20faf537e5\" not found"
      }
      
  • Invalid response: UUID with invalid format:

    • HTTP code: 422
    • Body:

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

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,
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Symfony kernel events table. Source: Symfony documentation

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.

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

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

Enter fullscreen mode Exit fullscreen mode

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 message2.

Depending on the body of the response, we would need to add more logic to the subscriber, for example, if we use Problem Details or JSON:API3.

If we autoconfigure 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

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,
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

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

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\ValueObject;

interface InvalidValueObject
{
}

Enter fullscreen mode Exit fullscreen mode
<?php

declare(strict_types=1);

namespace rubenrubiob\Domain\Exception\Repository;

interface NotFound
{
}

Enter fullscreen mode Exit fullscreen mode

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

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(),
            )
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

ExceptionResponseSubscriber: second version

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

<?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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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

  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" to learn about different alternatives. 

Top comments (0)