A guide to custom error handling in Symfony

By Marek Pietrzak

When working on a Symfony project some time ago, our team faced an issue with handling exceptions. An internal API needed to react to some explicit exceptions in a way that differs from the normal approach. So what did we do?

Thanks to the TwigBundle and to the _format: json parameter in the routing, the typical error response in the production environment looked like the below:

{"error":{"code":500,"message":"Internal Server Error"}}

We wanted to keep this functionality across the system, but for some explicit exceptions, we wanted something more descriptive, like:

{"error":{"code":400,"message":"Product with EAN13 1234567890123 does not exist"}}

One approach was to use a configuration from FOSRestBundle, passing custom exception codes and messages in the fos_restconfiguration.

But the solution came with a couple disadvantages:

  • we would have to rely on FOSRestBundle
  • a configuration would grew with each exception thrown in the system

We also didn't want to rely on a TwigBundle changing the way its ExceptionController handles exceptions. Nor did we want to throw HttpExceptions in the domain model code as HTTP is just the one of the adapters for the application.

The best and very clean solution here was to write our own event listener dispatched on the Symfony KernelException event. As this may be quite a common use case, here is a tutorial on how to write your custom exception listener in a Symfony-based application.

Solution

First, we need a Symfony application. For the demo purposes, I've created a repository here: https://github.com/mheki/ExceptionListenerDemo.

This simple application comes with the GreeterController, which is registered as a service. I personally prefer this approach to introduce more visibility for my controller’s dependencies. But there is no problem with extending Symfony base controller if one prefers that way. More information about how to define controllers as services can be found in the official Symfony documentation.

<?php

namespace App\Controller;

use App\Greeter\Greeter;
use Symfony\Component\HttpFoundation\JsonResponse;

class GreeterController
{
    private $greeter;

    public function __construct(Greeter $greeter)
    {
        $this->greeter = $greeter;
    }

    public function greetAction($name)
    {
        return new JsonResponse([
            'message' => $this->greeter->greet($name)
        ]);
    }
}

and a simple greeter service which has the responsibility of greeting people except thieves:

<?php

namespace App\Greeter;

use App\Exception\ThiefException;

final class SimpleGreeter implements Greeter
{
    public function greet($name)
    {
        if ('Thief' === $name) {
            throw new ThiefException('Attempted to greet a thief!');
        }

        return sprintf('Hello %s', $name);
    }
}

If you try to greet a thief, you get a standard HTML exception message which is a bit messy.

Let's try to fix it with the event listener.

First of all, we want to identify a contract of exceptions with the error messages published to the response.

Let's create a PublishedMessageException Interface:

<?php

namespace App\Exception;

interface PublishedMessageException
{
    public function getMessage();
}

We will need another interface, UserInputException. Exceptions implementing that interface will be raised by wrong data given by the user and result with 400 (bad request) response code instead of default 500.
The ThiefException class should implement both interfaces:

<?php
namespace App\Exception;

class ThiefException extends \Exception implements PublishedMessageException, UserInputException
{
}

Now we can create an exception listener, which will return a proper response. You can make it even more generic. For our use case we always wanted to return a JsonResponse.

Let's keep it as simple as possible:

<?php

namespace App\EventListener;

use App\Exception\PublishedMessageException;
use App\Exception\UserInputException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;

class PublishedMessageExceptionListener
{
    public function onKernelException(GetResponseForExceptionEvent $event)
    {
        $exception = $event->getException();

        if (!$exception instanceof PublishedMessageException) {
            return;
        }

        $code = $exception instanceof UserInputException ? 400 : 500;

        $responseData = [
            'error' => [
                'code' => $code,
                'message' => $exception->getMessage()
            ]
        ];

        $event->setResponse(new JsonResponse($responseData, $code));
    

The last thing left is to register our listener as a service with the correct tag:

services:
    published_message.listener:
        class: App\EventListener\PublishedMessageExceptionListener
        tags:
            - { name: kernel.event_listener, event: kernel.exception, method: onKernelException }

To check if the demo works, let's run the built-in server: bin/console server:run

Now sending a GET request: curl http://localhost:8000/greet/Marek will return a response:

{"message":"Hello Marek"}

Trying to greet a thief, however: curl http://localhost:8000/greet/Thief -i will return what we expected:

HTTP/1.1 400 Bad Request
Host: localhost:8000
Connection: close
X-Powered-By: PHP/5.6.9
Cache-Control: no-cache
Content-Type: application/json
Date: Sun, 28 Feb 2016 16:53:21 GMT

{"error":{"code":400,"message":"Attempted to greet a thief!"}}

Conclusion

This simple tutorial shows how useful, powerful and convenient Symfony event listeners are. They allow you to delegate some additional functionality, which can also be easily disabled if the requirements change.

Read more about event listeners in the official Symfony cookbook and the Event Dispatcher component documentation.