Easy Exception Handling in Symfony with Exception Listeners
For the past 16 years I've been professionally programming, other than for a year-long stint where I worked on Java, I primarily worked on PHP codebases. And by 'professionally programming', I simply mean that someone else paid for the computer and electricity running my code.
Most of my time has been spent on large PHP codebases that aren't based on any particular framework. But I've also worked on frameworks like Codeigniter, ZendFranework, CakePHP, and Laravel. Recently, I've had the chance to work on an old Symfony 5/6 codebase and I've been learning a lot of new things. Always fun to learn new things, let's dig in.
A common pattern I saw in the controller classes went something like this.
class MyController {
public function myAction(Request $request, Service $service) : JsonResponse
{
try {
$trickyRequestData = $request->toArray();
$service->doSomethingTricky($trickyRequestData);
} catch (NetworkException $e) {
Logger::getInstance()->error("Something went wrong in the network");
return new JsonResponse(['error' => 'A network exception happened'], 500);
} catch (DatabaseException $e) {
Logger::getInstance()->error("Something went wrong in the DB");
return new JsonResponse(['error' => 'A DB exception happened'], 500);
} catch (ServiceException $e) {
Logger::getInstance()->error("Something went wrong in the service");
return new JsonResponse(['error' => 'A service exception happened'], 500);
} catch (MyTrickyBadDataException $e) {
Logger::getInstance()->error("That was a bad request");
return new JsonResponse(['error' => 'You sent bad data'], 400);
} catch (Exception $e) {
Logger::getInstance()->error("Ooops, something went wrong");
return new JsonResponse(['error' => 'Something bad happened'], 500);
}
}
}
This is less than ideal for multiple reasons.
-
On applications that have a lot of these handlers spread across your controllers, adding support for a new exception becomes a painful exercise.
-
What if you wanted to change the format of your loggers or your response template? Again, a lot of busy work.
-
Every time something changes with the controllers and exceptions all your tests will also need to be updated in multiple places.
-
Your code is unnecessarily long.
"I choose a lazy person to do a hard job. Because a lazy person will find an easy way to do it." Bill Gates
I agree with Bill G about preferring an easier path. This is a perfect scenario for using an Event Listener. Symfony lets you define Event Listeners and Event Subscribers, the primary difference between them is that subscribers know all the events it is subscribed to and an event listener takes the more 'fire and forget' approach.
Let's create an event listener to handle the exceptions above.
Create a file named ExceptionListener.php
in the src/EventListener
folder (create the folder if you don't have it).
Exception listeners are just a simple class with an __invoke
method that
gets called with the event data. So we start with something that looks like
this.
class ExceptionListener
{
public function __invoke(ExceptionEvent $event): void
{
$ex = $event->getThrowable();
Logger::getInstance()->error("Ooops, something went wrong");
$event->setResponse(new JsonResponse(['error' => 'Something bad happened'], 500));
}
}
This code is going to respond with a generic error anytime an exception bubbles up and isn't handled elsewhere. This isn't useful unless you can handle the different exception types. So let's add that.
My approach ended up looking like what's shown below. While your approach may vary, the basic structure is likely going to be the same.
class ExceptionListener
{
public function __invoke(ExceptionEvent $event): void
{
$ex = $event->getThrowable();
$error = match ($ex::class) {
NetworkException::class => $this->getResponse($ex, "A network exception happened", Response::HTTP_INTERNAL_SERVER_ERROR),
DatabaseException::class => $this->getResponse($ex, "A DB exception happened", Response::HTTP_INTERNAL_SERVER_ERROR),
ServiceException::class => $this->getResponse($ex, "A service exception happened", Response::HTTP_INTERNAL_SERVER_ERROR),
MyTrickyBadDataException::class => $this->getResponse($ex, "That was a bad request", Response::HTTP_BAD_REQUEST),
default => $this->getResponse($ex, "Ooops, something went wrong.", Response::HTTP_INTERNAL_SERVER_ERROR)
};
Logger::getInstance()->error($ex->getMessage(), [
'trace' => $ex->getTrace()
]);
$event->setResponse($error);
}
protected function getResponse(\Throwable $ex, string $message, int $errorCode) : JsonResponse
{
return new JsonResponse([
'error' => $message,
'detail' => "Exception of type ".$ex::class." occurred. With message: {$ex->getMessage()}"
], $errorCode);
}
}
In my case, I don't care about leaking exception information since this is an internal API. Depending on your requirement, you could make the final response as general or detailed as you want. You can also control what gets logged.
With this code in place, you can remove all the individual exception handlers in your controllers. You can still handle specific classes of exceptions in your controllers or other classes, but everything else would just bubble up to the listener and get handled in a standardized way.
Now you can submit the PR to delete all that unused code.
- Previous: Hello! It's Been Awhile
- Next: The Passport Saga