Centralized exception handling with Symfony and custom PHP attributes
Over time, as our application grows, the controllers can become cluttered with repeated exception handling code. In this article we’ll see how we can have both cleaner controller methods when using the Symfony framework by centralizing the exception handling, and cleaner domain exceptions by only declaring custom PHP attributes on their classes.
Let’s imagine that we are developing an e-commerce application that can process and manage orders made by the website’s visitors. One of the functionalities is the ability to cancel a specific order. However, an order can be cancelled only if these three conditions are satisfied:
- it must be present (obviously)
- it must not be shipped yet
- only the customer who created it can cancel it
If there is an attempt to cancel an order without satisfying all the conditions, we are throwing one of the following exceptions:
Cancelling the order and handling the exceptions
One way to handle the possibly thrown exceptions is to catch them in the controller where we initiate the cancellation (either directly or through some other service, like a command handler). Then we can build the response, assign the suitable status code to it, and return it to the client.
As we can see, most of the code in our method is used for handling the exceptions. This will likely happen with most
of the other controller methods in our application as well. Another negative implication is that whenever we start
throwing a new similar exception somewhere in our domain layer, we’ll also have to add another catch block in all
controllers it may end up to. That can span over multiple API endpoints, thus we’ll need to update multiple controller
methods (for example, we may also throw an OrderAlreadyShipped
exception if somebody tries to add a new item to an
already shipped order.)
Moving the exception handling
As a first step, let’s remove the entire try-catch block from the controller method:
The controller method is more clear now, and we can easily see it’s intention. Note that as a trade-off, we’re losing some visibility over what can go wrong during the execution of the methods we’re calling.
If we try to cancel an already shipped order now, as expected since we removed the exception handling, we’ll get the Symfony’s default error page.
The order “213ba2c0-d82c-4805-8de4-773d20f3cbe3” is already shipped.
HTTP 500 Internal Server Error - OrderAlreadyShipped
When an exception is thrown during the handling of the request, and is not explicitly caught and handled, the Symfony’s
HttpKernel dispatches an
kernel.exception
event.
To centralize the exception handling, we can create a listener that will listen to this type of events, and that will
take care of preparing the response for the client. When such event is dispatched, all listeners that are supposed to
handle it receive an instance of the ExceptionEvent
class, from which we can get the thrown exception object.
Now, whenever there’s an unhandled exception in our application, we will receive an event in the listener we just created. However, we don’t want all the exceptions handled here, as not everything is intended to be shown to the client, so we need a way to determine if the received exception should be handled or not.
The domain exceptions we have in our application so far can be divided in 3 groups, each associated with an appropriate HTTP status code.
Exception | Status Code |
---|---|
OrderNotFound | 404 Not Found |
CustomerMismatch | 403 Forbidden |
OrderAlreadyShipped | 422 Unprocessable Entity |
For each of the HTTP status codes that we want to support we’ll create a separate custom PHP attribute. To make sure all of them are providing a specific status code, the attributes will implement the following interface:
Here are the three attributes we need so far:
Next, we’ll declare the new attributes on the existing exception classes:
Back to the listener, we can now use the Reflection API to get the attributes declared on the thrown exception’s class.
As any class can have multiple attributes declared on it, we can use the fact that all our attributes implement the
StatusCodeProvider
interface and filter out the non-related ones. If no such attribute is declared on the class, it
means that we don’t support handling this exception in the handler. If multiple such attributes are declared, we’ll just
use the first one.
If the exception is not supported, we can simply ignore it and let it be handled somewhere else. Otherwise, we need to extract the needed data and build the response we’d want to return to the client.
For the response’s content we can use the message provided by the exception, and we can get the needed HTTP status code from the declared attribute that we fetched above.
When the response is prepared, we need to pass it along by updating the received event object using the setResponse
method.
This behaviour can be changed by calling
allowCustomResponseCode()
on the event object, but
doing that is discouraged by the
docs.
If we try to cancel the same order again, this time we’ll get the expected JSON data, with 422 Unprocessable Entity as status code.
Next steps
Now that we have the attributes for adding the needed metadata, and the handler which will handle the marked exceptions, we can easily add more use cases.
For example, if we have a functionality for applying promo codes to the orders, and we check if the given code is still
valid, we can have the following exception, and just declare the UnprocessableEntity
attribute on it:
If we want to support more status codes, we only need to create a new attribute that will implement the
StatusCodeProvider
interface and start declaring it on the exceptions.
Sometimes, it may happen that we may want to return a different message to the client instead of the one we have in
the exception (which we may still want to keep for other purposes). For such cases, we can extend the attributes by
adding an optional message
argument to each of them that can be specified when declaring it on an exception. Then in
the handler we can use this message if provided, or the exception’s one if not.
The events of the ExceptionEvent
class also contain the request object, which can be useful to decide in which format
we should respond to the client. If we also have controllers that render templates and provide HTML content, we can
create an additional error handler that will set a flash message and redirect the client to the previous page, if the
exception is thrown while executing a request from those pages. This can be also useful if we have multiple bounded
contexts, so we can have multiple similar handlers that will cover exceptions from different areas in the application.
Photo for social media by
Vie Studio.