Handling Exceptions in a Symfony Application

There are two main types of application errors:

  • The request can be processed, but the data is not valid
  • The request could not be processed due to a broken part of the application or system

If the requested URL is not found because it is not defined in the API specification, then an HTTP 404 (Not Found) response is returned.

The request can be processed, but the data is not valid refers to a server response with an HTTP code 400 (Bad Request) detailing exactly which data sent is invalid.
Errors of this kind are foreseen in advance and their handling is a normal part of the application.
For example, after submitting the news form data to the server, one of the fields did not pass validation and the server returned an HTTP 400 (Bad Request) response with error details to be shown to the user.
This type of error can not be logged, because error details are returned immediately with the server response and no server-side fixes are required.
This kind of error displays the same response data for prod and dev environments.

The request cannot be processed due to a broken part of the application or system refers to a server response with HTTP code 500 (Internal Server Error) and hides the details of which part of the application or system is not working.
Errors of this kind cannot be foreseen in advance, and their processing indicates a clear breakdown of a part of the application or some kind of system.
For example, a value of type string was passed to the method parameter, but the type must be int, or the database, mail server or SMS service is not available.
This kind of error displays different data depending on the prod or dev environment.
For prod environment - error details are hidden and logging is enabled.
For dev environment - show all error details with stack trace and disable logging.

Logging is often useful in a prod environment and makes little sense in a dev environment, since in the dev environment, the details of the error should be shown immediately in the response from the server.

Creating a Response when an Error occurs

Consider a simple application with news functionality that throws a domain error and an uncatchable exception.

News Controller

namespace App\Controller;

class NewsController extends AbstractController
{
    // ...
    #[Route('/news', 'news_add', methods: ['POST'])]
    public function add(Request $request, NewsPersister $newsPersister): JsonResponse
    {
        $news = $this->serializer->deserialize($request->getContent(), News::class, 'json');

        try {
            $news = $newsPersister->persist($news);
        } catch(PersisterException $exception) {
            return new JsonResponse(
                ['details' => $exception->getMessage()],
                Response::HTTP_BAD_REQUEST
            );
        }
        
        return $this->json($news);
    }
    // ...

This code does the following:
The add action adds news on the POST /news request.
The sent data is deserialized from JSON into a News object.
The news is stored using the NewsPersister service.
If the expected PersisterException is thrown during the save process, then an HTTP 400 (Bad Request) response is returned detailing the error.
If the news is saved, the news object will be serialized to JSON and an HTTP 200 (OK) response will be sent.

News Saving Service

namespace App\Service\News;
// ...
class NewsPersister
{
    public function __construct(
        private readonly NewsRepository $newsRepository
    ) {}
    
    public function persist(News $news): News
    {
        if ($this->newsRepository->findOneBy(['title' => $news->getTitle()])) {
            throw new PersisterException(
                sprintf('News with title "%s" already exists', $news->getTitle())
            );
        }

        $this->newsRepository->add($news, true);
        
        return $news;
    }
}

The NewsPersister service checks if such news has already been added before,
and if so, a PersisterException is thrown, otherwise the news is added.

Exception when Saving News

namespace App\Service\News\Exception;

class PersisterException extends \Exception
{
}

A PersisterException is thrown when an error occurs in the news save process.

News Repository

namespace App\Repository;
// ...
class NewsRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, News::class);
    }

    public function add(News $entity, bool $flush = false): void
    {
        $this->getEntityManager()->persist($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }
}

The NewsRepository executes find requests and adds the news.

Testing

Let's test the functionality of adding news in the dev environment.
To display the JSON response in a formatted form, we need the jq utility.
To install jq, run the command:

apk add jq

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

{
  "id": 2,
  "title": "News of the week",
  "Text": "A lot of new and interesting things happened this week"
}

HTTP 200 (OK) response. The news has been added. Now let's try adding it again.

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

{
  "detail": "News with title \"News of the week\" already exists"
}

HTTP 400 (Bad Request) response. The news was not added. it has already been added before, i.e. the code catching the PersisterException exception worked.

try {
    $news = $newsPersister->persist($news);
} catch(PersisterException $exception) {
    return new JsonResponse(
        ['detail' => $exception->getMessage()],
        Response::HTTP_BAD_REQUEST
    );
}

But what happens if another exception is thrown. For example, the database is broken. In our case, this is SQLite. Let's make a change that breaks access to the file that stores the database.

# .env
# Non-working case, because no such file "data2.db"
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data2.db"

Execute the request again.

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

[
  {
    "message": "An exception occurred while executing a query: SQLSTATE[HY000]: General error: 1 no such table: news",
    "class": "Doctrine\\DBAL\\Exception\\TableNotFoundException",
    "trace": [
      {
        "namespace": "",
        "short_class": "",
        "class": "",
        "type": "",
        "function": "",
        "file": "/app/vendor/doctrine/dbal/src/Driver/API/SQLite/ExceptionConverter.php",
        "line": 56,
        "args": []
      },
// ...

An HTTP 500 (Internal Server Error) response will be returned with a stack trace of the exception.
This is useful information that will tell you the cause of the error.

Let's change the environment from dev to prod and see what will be output in this case.

# .env

APP_ENV=prod

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

{
  "type": "https://tools.ietf.org/html/rfc2616#section-10",
  "title": "An error occurred",
  "status": 500,
  "detail": "Internal Server Error"
}

When an uncaught exception is thrown in an application, it is handled by the Symfony kernel.
Such exception handling just happened in the previous two requests - the database was not available and there were no appropriate try {...} catch (...) to catch general exceptions - Exception.

Catching such exceptions is in the HttpKernel class.

// vendor/symfony/http-kernel/HttpKernel.php

public function handle(Request $request, int $type = HttpKernelInterface::MAIN_REQUEST, bool $catch = true): Response
{
    $request->headers->set('X-Php-Ob-Level', (string) ob_get_level());

    try {
        return $this->handleRaw($request, $type);
    } catch (\Exception $e) {
        if ($e instanceof RequestExceptionInterface) {
            $e = new BadRequestHttpException($e->getMessage(), $e);
        }
        if (false === $catch) {
            $this->finishRequest($request, $type);

            throw $e;
        }

        return $this->handleThrowable($e, $request, $type);
    }
}

After catching the exception, the handleThrowable method is called.

// vendor/symfony/http-kernel/HttpKernel.php

private function handleThrowable(\Throwable $e, Request $request, int $type): Response
{
    $event = new ExceptionEvent($this, $request, $type, $e);
    $this->dispatcher->dispatch($event, KernelEvents::EXCEPTION);
    // ...
}

The KernelEvents::EXCEPTION event is dispatched and handled by the ErrorListener listener.

// vendor/symfony/http-kernel/EventListener/ErrorListener.php

public function onKernelException(ExceptionEvent $event)
{
    if (null === $this->controller) {
        return;
    }

    $throwable = $event->getThrowable();
    $request = $this->duplicateRequest($throwable, $event->getRequest());

    try {
        $response = $event->getKernel()->handle($request, HttpKernelInterface::SUB_REQUEST, false);
    } catch (\Exception $e) {
    // ...
}

The $request is handled by the kernel which holds the ErrorController.

// vendor/symfony/http-kernel/Controller/ErrorController.php

class ErrorController
{
    private HttpKernelInterface $kernel;
    private string|object|array|null $controller;
    private ErrorRendererInterface $errorRenderer;

    public function __construct(HttpKernelInterface $kernel, string|object|array|null $controller, ErrorRendererInterface $errorRenderer)
    {
        $this->kernel = $kernel;
        $this->controller = $controller;
        $this->errorRenderer = $errorRenderer;
    }

    public function __invoke(\Throwable $exception): Response
    {
        $exception = $this->errorRenderer->render($exception);

        return new Response($exception->getAsString(), $exception->getStatusCode(), $exception->getHeaders());
    }
    // ...

Next, errorRenderer is called, in this case it will be SerializerErrorRenderer.
In the standard case, errorRenderer is a chain of calls to SerializerErrorRenderer -> TwigErrorRenderer -> HtmlErrorRenderer. How this chain is set up can be found by searching the code for the service name error_renderer.

If the SerializerErrorRenderer did not find a suitable format taken from the Accept HTTP header, it will default to the HTML format. If the format was set to the default (HTML), then after an unsuccessful attempt to serialize the data, a TwigErrorRenderer will be called.

The TwigErrorRenderer will try to find a template for an HTTP status code exception (e.g.
templates/bundles/TwigBundle/error404.html.twig, templates/bundles/TwigBundle/error500.html.twig or
templates/bundles/TwigBundle/error.html for all other errors) and if it doesn't find it, it will call HtmlErrorRenderer.

The HtmlErrorRenderer renders the default "Oops! An Error Occurred" error page for the prod environment, or a debug page with a stack trace for the dev environment.

// vendor/symfony/error-handler/ErrorRenderer/SerializerErrorRenderer.php

public function render(\Throwable $exception): FlattenException
{
    $headers = [];
    $debug = \is_bool($this->debug) ? $this->debug : ($this->debug)($exception);
    if ($debug) {
        $headers['X-Debug-Exception'] = rawurlencode($exception->getMessage());
        $headers['X-Debug-Exception-File'] = rawurlencode($exception->getFile()).':'.$exception->getLine();
    }

    $flattenException = FlattenException::createFromThrowable($exception, null, $headers);

    try {
        $format = \is_string($this->format) ? $this->format : ($this->format)($flattenException);
        $headers = [
            'Content-Type' => Request::getMimeTypes($format)[0] ?? $format,
            'Vary' => 'Accept',
        ];

        return $flattenException->setAsString($this->serializer->serialize($flattenException, $format, [
            'exception' => $exception,
            'debug' => $debug,
        ]))
        ->setHeaders($flattenException->getHeaders() + $headers);
    } catch (NotEncodableValueException) {
        return $this->fallbackErrorRenderer->render($exception);
    }
}

In the render method, the FlattenException is serialized.
The serializer looks for a suitable normalizer and finds the ProblemNormalizer.

// vendor/symfony/serializer/Normalizer/ProblemNormalizer.php

class ProblemNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
{
    // ...

    /**
     * {@inheritdoc}
     */
    public function normalize(mixed $object, string $format = null, array $context = []): array
    {
        if (!$object instanceof FlattenException) {
            throw new InvalidArgumentException(sprintf('The object must implement "%s".', FlattenException::class));
        }

        $context += $this->defaultContext;
        $debug = $this->debug && ($context['debug'] ?? true);

        $data = [
            self::TYPE => $context['type'],
            self::TITLE => $context['title'],
            self::STATUS => $context['status'] ?? $object->getStatusCode(),
            'detail' => $debug ? $object->getMessage() : $object->getStatusText(),
        ];
        if ($debug) {
            $data['class'] = $object->getClass();
            $data['trace'] = $object->getTrace();
        }

        return $data;
    }

    /**
     * {@inheritdoc}
     *
     * @param array $context
     */
    public function supportsNormalization(mixed $data, string $format = null /* , array $context = [] */): bool
    {
        return $data instanceof FlattenException;
    }
    // ...
}

The normalize method generates a data for response with an error message and a stack trace.

In order not to write a try {...} catch(...) in each controller action, you can create an ExceptionNormalizer that will handle all exceptions and a DomainException that will contain a message for the user.

After the changes, the add method of the NewsController will look like this.

namespace App\Controller;

#[Route('/news', 'news_add', methods: ['POST'])]
public function add(Request $request, NewsPersister $newsPersister): JsonResponse
{
    $news = $this->serializer->deserialize($request->getContent(), News::class, 'json');

    $news = $newsPersister->persist($news);
    
    return $this->json($news);
}

That is, the try {...} catch(...) block was removed.

The NewsPersister service will look like this.

namespace App\Service\News;

public function persist(News $news): News
{
    if ($this->newsRepository->findOneBy(['title' => $news->getTitle()])) {
        throw new DomainException(
            sprintf('News with title "%s" already exists', $news->getTitle()),
        );
    }

    $this->newsRepository->add($news, true);
    
    return $news;
}

Replaced PersisterException with DomainException.

The DomainException will look like this.

namespace App\HttpKernel\Exception;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class DomainException extends \Exception implements HttpExceptionInterface
{
    public function __construct(
        string $message = "",
        private readonly int $status = Response::HTTP_BAD_REQUEST,
        int $code = 0,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }

    public function getStatusCode(): int
    {
        return $this->status;
    }
    
    public function getHeaders(): array
    {
        return [];
    }
}

Let's add ExceptionNormalizer so that the normalizer can handle uncaught and DomainException exceptions.

// src/Serializer/Normalizer/ExceptionNormalizer.php
class ExceptionNormalizer implements NormalizerInterface
{
    public function __construct(#[Autowire('%kernel.debug%')] private readonly bool $debug = false) {}

    /**
     * @param FlattenException $object
     */
    public function normalize(mixed $object, string $format = null, array $context = []): array
    {
        if (!$object instanceof FlattenException) {
            throw new InvalidArgumentException(sprintf('The object must implement "%s".', FlattenException::class));
        }
        
        // in all environments for domain exceptions only a message will be shown
        if ($object->getClass() === DomainException::class) {
            return ['message' => $object->getMessage()];
        }
        
        // in dev and test environment all data of the exception will be shown
        if ($this->debug) {
            return $object->toArray();
        }

        // in prod environment no data will be shown
        return [];
    }
    
    public function supportsNormalization(mixed $data, string $format = null): bool
    {
        return $data instanceof FlattenException;
    }
}

Let's check how the error handling functionality for requests in JSON format will now work.

In the dev environment, throwing a DomainException.

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

{
  "message": "News with title \"News of the week\" already exists"
}

In the prod environment, throwing a DomainException.

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

{
  "message": "News with title \"News of the week\" already exists"
}

Response will be same as in the dev environment.

In the dev environment, throwing an uncaught exception.

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

[
  {
    "message": "An exception occurred while executing a query: SQLSTATE[HY000]: General error: 1 no such table: news",
    "class": "Doctrine\\DBAL\\Exception\\TableNotFoundException",
    "trace": [
      {
        "namespace": "",
        "short_class": "",
        "class": "",
        "type": "",
        "function": "",
        "file": "/app/vendor/doctrine/dbal/src/Driver/API/SQLite/ExceptionConverter.php",
        "line": 56,
        "args": []
      },
...

In the prod environment, throwing an uncaught exception.

Request

curl http://127.0.0.1:8000/news \
-d '{"title":"News of the week","text":"A lot of new and interesting things happened this week"}' \
--header "Accept: application/json" \
-v | jq

Response

[]

Now let's check how the error handling functionality will work for requests in HTML format.

The most common errors that occur when requesting web pages are HTTP 404 (Not Found) and HTTP 500 (Internal Server Error). When these errors occur, special HTML pages should be output.

Before that, you need to install the Twig bundle and create templates for errors with HTTP codes 500, 404 and all others:
/templates/bundles/TwigBundle/Exception/error500.html.twig
/templates/bundles/TwigBundle/Exception/error404.html.twig
/templates/bundles/TwigBundle/Exception/error.html.twig

An example of the content of the template for an error with HTTP code 404.

<!-- /templates/bundles/TwigBundle/Exception/error404.html.twig -->
<html>
<body>
<h1>404 Error Page</h1>

<p>Status code: {{ status_code }}</p>
<p>Status text: {{ status_text }}</p>
</body>
</html>

In the dev environment, throwing an uncaught exception.

Request

curl http://127.0.0.1:8000/news

Response

<!-- An exception occurred while executing a query: SQLSTATE[HY000]: General error: 1 no such table: news (500 Internal Server Error) -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <meta name="robots" content="noindex,nofollow" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>An exception occurred while executing a query: SQLSTATE[HY000]: General error: 1 no such table: news (500 Internal Server Error)</title>
    // ...

That is, a debug page was displayed with a detailed description of the error and a stack trace.

With the current dev environment, to see what an error page would look like for a particular HTTP code in the prod environment, you can go to the URL http://127.0.0.1:8000/_error/500.

In the prod environment, throwing an uncaught exception.

Request

curl http://127.0.0.1:8000/news

Response

<html>
<body>
<h1>500 Error Page</h1>

<p>Status code: 500</p>
<p>Status text: Internal Server Error</p>
</body>
</html>

As a result, a functionality was developed that handles uncaught exceptions and domain exceptions through the ExceptionNormalizer, taking into account the dev and prod environments for JSON requests.
For HTML requests, Twig templates have been developed for HTTP 404 (Not Found) and HTTP 500 (Internal Server Error) exceptions.