Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Global RESTful Exception Handling

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When we throw an ApiProblemException, we need our app to automatically turn that into a nicely-formatted API Problem JSON response and return it. That code will look like what we have down here for validation, but it needs to live in a global spot.

Whenever an exception is thrown in Symfony, it dispatches an event called kernel.exception. If we attach a listener function to that event, we can take full control of how exceptions are handled. If creating a listener is new to you, we have a chapter on that in our Journey series called Interrupt Symfony with an Event Subscriber.

In AppBundle, create an EventListener directory. Add a new class in here called ApiExceptionSubscriber and make sure it's in the AppBundle\EventListener namespace:

... lines 1 - 2
namespace AppBundle\EventListener;
class ApiExceptionSubscriber
{
}

There are two ways to hook into an event: via a listener or a subscriber. They're really the same thing, but I think subscribers are cooler. To hook one up, make this class implement EventSubscriberInterface - the one from Symfony. Now, hit cmd+n - or go to the the Code->Generate menu - select "Implement Methods" and select getSubscribedEvents. That's a fast way to generate the one method from EventSubscriberInterface that we need to fill in:

... lines 1 - 4
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ApiExceptionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
// TODO: Implement getSubscribedEvents() method.
}
}

Return an array with one just entry. The key is the event's name - use KernelEvents::EXCEPTION - that's really just the string kernel.exception. Assign that to the string: onKernelException. That'll be the name of our method in this class that should be called whenever an exception is thrown. Create that method: public function onKernelException():

... lines 1 - 5
use Symfony\Component\HttpKernel\KernelEvents;
... line 7
class ApiExceptionSubscriber implements EventSubscriberInterface
{
public function onKernelException()
{
}
public static function getSubscribedEvents()
{
return array(
KernelEvents::EXCEPTION => 'onKernelException'
);
}
}

So once we tell Symfony about this class, whenever an exception is thrown, Symfony will call this method. And when it does, it'll pass us an $event argument object. But what type of object is that? Hold cmd - or control for Windows and Linux - and click the EXCEPTION constant. The documentation above it tells us that we'll be passed a GetResponseForExceptionEvent object. Close that class and type-hint the event argument. Don't forget your use statement:

... lines 1 - 5
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
... lines 7 - 8
class ApiExceptionSubscriber implements EventSubscriberInterface
{
public function onKernelException(GetResponseForExceptionEvent $event)
{
}
... lines 15 - 21
}

The Subscriber Logic

Listeners to kernel.exception have one big job: to try to understand what went wrong and return a Response for the error. The big exception page we see in dev mode is caused by a core Symfony listener to this same event. We throw an exception and it gives us the pretty exception page.

So our missing is clear: detect if an ApiProblemException was thrown and create a nice Api Problem JSON response if it was.

First, to get access to the exception that was just thrown, call getException() on the $event. So far, we only want our listener to act if this is an ApiProblemException object. Add an if statment: if !$e instanceof ApiProblemException, then just return immediately:

... lines 1 - 11
public function onKernelException(GetResponseForExceptionEvent $event)
{
$e = $event->getException();
if (!$e instanceof ApiProblemException) {
return;
}
}
... lines 19 - 27

For now, that'll mean that normal exceptions will still be handled via Symfony's core listener.

Now that we know this is an ApiProblemException, let's turn it into a Response. Go steal the last few lines of the validation response code from ProgrammerController. Put this inside onKernelException(). You'll need to add the use statement for JsonResponse manually:

... lines 1 - 12
public function onKernelException(GetResponseForExceptionEvent $event)
{
$e = $event->getException();
if (!$e instanceof ApiProblemException) {
return;
}
... lines 19 - 21
$response = new JsonResponse(
$apiProblem->toArray(),
$apiProblem->getStatusCode()
);
$response->headers->set('Content-Type', 'application/problem+json');
... lines 27 - 28
}
... lines 30 - 38

But we don't have an $apiProblem variable yet. There is an ApiProblem object inside the ApiProblemException as a property, but we don't have a way to access it yet. Go back to the Generate menu - select Getters - and choose the apiProblem property:

... lines 1 - 6
class ApiProblemException extends HttpException
{
... lines 9 - 19
public function getApiProblem()
{
return $this->apiProblem;
}
}

In the subscriber, we can say $apiProblem = $e->getApiProblem():

... lines 1 - 10
class ApiExceptionSubscriber implements EventSubscriberInterface
{
public function onKernelException(GetResponseForExceptionEvent $event)
{
... lines 15 - 19
$apiProblem = $e->getApiProblem();
$response = new JsonResponse(
$apiProblem->toArray(),
$apiProblem->getStatusCode()
);
$response->headers->set('Content-Type', 'application/problem+json');
... lines 27 - 28
}
... lines 30 - 36
}

This is now exactly the Response we want to send back to the client. To tell Symfony to use this, call $event->setResponse() and pass it the $response:

... lines 1 - 12
public function onKernelException(GetResponseForExceptionEvent $event)
{
... lines 15 - 27
$event->setResponse($response);
}
... lines 30 - 38

Registering the Event Subscriber

There's just one more step left: telling Symfony about the subscriber. Go to app/config/services.yml. Give the service a name - how about api_problem_subscriber. Then fill in the class with ApiExceptionSubscriber and give it an empty arguments key. The secret to telling Symfony that this service is an event subscriber is with a tag named kernel.event_subscriber:

... lines 1 - 5
services:
... lines 7 - 19
api_exception_subscriber:
class: AppBundle\EventListener\ApiExceptionSubscriber
arguments: []
tags:
- { name: kernel.event_subscriber }

That tag is enough to tell Symfony about our subscriber - it'll take care of the rest.

Head back to our test where we send invalid JSON and expect the 400 status code. This already worked before, but the response was HTML, so the next assert - for a JSON response with a type property - has been failing hard. Actually, I totally messed up that assert earlier - make sure you're asserting a type key, not test:

... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 147
public function testInvalidJson()
{
... lines 150 - 162
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'invalid_body_format');
}
}

So, type should be set to invalid_body_format because the ApiProblem has that type set via the constant:

... lines 1 - 18
class ProgrammerController extends BaseController
{
... lines 21 - 142
private function processForm(Request $request, FormInterface $form)
{
$data = json_decode($request->getContent(), true);
if ($data === null) {
$apiProblem = new ApiProblem(400, ApiProblem::TYPE_INVALID_REQUEST_BODY_FORMAT);
throw new ApiProblemException($apiProblem);
}
... lines 151 - 153
}
... lines 155 - 187
}
}

But with the exception subscriber in place, we should now get the JSON response we want. Ok, moment of truth:

./bin/phpunit -c app --filter testInvalidJson

It passes! This is huge! We now have a central way for triggering and handling errors. Take out the debugResponse() call.

Celebrate by throwing an ApiProblemException for validations errors too. Replace all the Response-creation logic in createValidationErrorResponse() with a simple throw new ApiProblemException() and pass it the $apiProblem:

... lines 1 - 18
class ProgrammerController extends BaseController
{
... lines 21 - 173
private function createValidationErrorResponse(FormInterface $form)
{
... lines 176 - 183
throw new ApiProblemException($apiProblem);
}
}

That's all the code we need now, no matter where we are. And now, the method name - createValidationErrorResponse() isn't really accurate. Change it to throwApiProblemValidationException():

... lines 1 - 18
class ProgrammerController extends BaseController
{
... lines 21 - 173
private function throwApiProblemValidationException(FormInterface $form)
{
... lines 176 - 183
throw new ApiProblemException($apiProblem);
}
}

Search for the 2 spots that use that and update the name. And we don't need to have a return statement anymore: just call the function and it'll throw the exception for us:

... lines 1 - 24
public function newAction(Request $request)
{
... lines 27 - 30
if (!$form->isValid()) {
$this->throwApiProblemValidationException($form);
}
... lines 34 - 48
}
... lines 50 - 91
public function updateAction($nickname, Request $request)
{
... lines 94 - 107
if (!$form->isValid()) {
$this->throwApiProblemValidationException($form);
}
... lines 111 - 118
}
... lines 120 - 185
}

Re-test everything:

./bin/phpunit -c app

Now we're green and we can send back exciting error responses from anywhere in our code. But what about other exceptions, like 404 exceptions?

Leave a comment!

8
Login or Register to join the conversation

YAGT! Yet Another Great Tutorial.

So I am using these concepts in Symfony 4, and one of the great new features is the automatic lookup for an entity when you type hint the controller method with the Entity Class like this:


 /**
   * @Route("/api/veconfig/{VEConfigUID}", name="getVEConfig", methods={"GET"})
   */
  public function getVEConfigAction(VEConfig $VEConfig)
  {
    if (!$VEConfig) {
      $this->throwApiProblemVEConfigNotFoundException($VEConfig->getVEConfigUID());
    }
    return new JsonResponse($this->serialize($VEConfig), 200, [], true);
  }

The problem is that the @ParamConverter catches the error first. That is not so bad, but I wanted the message to better than "App\Entity\VEConfig object not found by the @ParamConverter annotation."

Something like "Your VE Configuration could not be found. VEConfigUID = %s", $VEConfigUID

The only thing I could think of was to hack the apiExceptionSubscriber, and intercept the message, but that was not clean. Any Ideas?

Reply
Mouad E. Avatar
Mouad E. Avatar Mouad E. | Skylar | posted 3 years ago | edited

Hi Skylar after 2 years is it still unable to custom the exception thrown by ParamConverter(NotFoundHttpException) in a clean way?

Reply

Hey Mouad E.

I guess it's not a problem that I'm not Ryan))) But I'll try to answer your question! Unfortunately there is still no way to modify default exception, so Ryan's answer is still relevant, you can make your argument optional, and then check it and throw exception manually. And I think there is a hard way))) create your own ParamConverter if you need it to be more reusable.

Cheers!

Reply

Hey Skylar!

Hmm, yes, indeed! The ParamConverter only gives you that generic message... which is normally fine (in an HTML website where you don't show any error)... but I see what you're trying to do! And yea... there's no clean way to do this: the exception thrown by ParamConverter is a generic NotFoundHttpException. So, apart from parsing the string of the message, there's no way for you to cleanly "catch" that the 404 is caused by the ParamConverter. The only solution I can think of is to make your argument optional VEConfig $VEConfig = null. I believe this will cause the ParamConverter to NOT 404, and then your if statement will work as you expect.

I hope that idea helps :).

Cheers!

Reply
Default user avatar
Default user avatar DevDonkey | posted 5 years ago

the amount of time Ive revisited this course is amazing.

[Possibly bad idea] You have so much really cool stuff knocking about for testing API's, I wonder if its worth making a KNP ApiTestingBundle? I find myself copying this stuff around so much into side projects.. I bet others do too.

Reply

Yea, I've thought about that too - I'll add it to my open source list - I think it might be nice (and would let the community add more ideas to it).

Cheers!

Reply
Default user avatar

I for one would be happy to contribute as much as I could.

Reply
Default user avatar
Default user avatar Mike Ritter | posted 5 years ago

"message will self destruct"

-1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony. The concepts of REST and errors are still valid, but I recommend using API Platform in new Symfony apps.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.3.3",
        "symfony/symfony": "2.6.*", // v2.6.11
        "doctrine/orm": "~2.2,>=2.2.3,<2.5", // v2.4.7
        "doctrine/dbal": "<2.5", // v2.4.4
        "doctrine/doctrine-bundle": "~1.2", // v1.4.0
        "twig/extensions": "~1.0", // v1.2.0
        "symfony/assetic-bundle": "~2.3", // v2.6.1
        "symfony/swiftmailer-bundle": "~2.3", // v2.3.8
        "symfony/monolog-bundle": "~2.4", // v2.7.1
        "sensio/distribution-bundle": "~3.0,>=3.0.12", // v3.0.21
        "sensio/framework-extra-bundle": "~3.0,>=3.0.2", // v3.0.7
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.0
        "hautelook/alice-bundle": "0.2.*", // 0.2
        "jms/serializer-bundle": "0.13.*" // 0.13.0
    },
    "require-dev": {
        "sensio/generator-bundle": "~2.3", // v2.5.3
        "behat/behat": "~3.0", // v3.0.15
        "behat/mink-extension": "~2.0.1", // v2.0.1
        "behat/mink-goutte-driver": "~1.1.0", // v1.1.0
        "behat/mink-selenium2-driver": "~1.2.0", // v1.2.0
        "phpunit/phpunit": "~4.6.0" // 4.6.4
    }
}
userVoice