In this example we are going to catch all our custom exceptions and log them in a log file. Apart from that, we will return an appropriate HTTP status code and text message to the user.


When developing an API application, catch all exceptions and throw your custom ones. This way, you have full control over "proper" exception handling as shown in this example. The whole point here is that, returning sensible responses to the end user, not accidental 500 codes!


monolog.yaml


Add channels: ['api'] entry to all "monolog.yaml" files so that our custom errors can be logged in "test".log", "dev".log" and "prod".log" files. Or, just create a custom handler to log them in custom log file instead.


monolog:
channels: ['api']
handlers:
...

ApiExceptionInterface


All your custom exceptions should implement this.


declare(strict_types=1);

namespace App\Exception;

interface ApiExceptionInterface
{
}

CountryException


All your custom exceptions should be same as this.


declare(strict_types=1);

namespace App\Exception;

use Exception;
use Symfony\Component\HttpKernel\Exception\HttpException;

class CountryException extends HttpException implements ApiExceptionInterface
{
public function __construct(string $message, int $code, Exception $previous = null)
{
parent::__construct($code, $message, $previous);
}
}

ExceptionListener


If your API returns request validation errors as in JSON form, you can modify the response message below to meet your needs, such as returning a JSON response instead.


declare(strict_types=1);

namespace App\Event;

use App\Exception\ApiExceptionInterface;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;

class ExceptionListener
{
private $logger;

public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}

public function onKernelException(GetResponseForExceptionEvent $event)
{
if (!$event->getException() instanceof ApiExceptionInterface) {
return;
}

$response = new Response($event->getException()->getMessage(), $event->getException()->getStatusCode());

$event->setResponse($response);

$this->log($event->getException());
}

private function log(ApiExceptionInterface $exception)
{
$log = [
'code' => $exception->getStatusCode(),
'message' => $exception->getMessage(),
'called' => [
'file' => $exception->getTrace()[0]['file'],
'line' => $exception->getTrace()[0]['line'],
],
'occurred' => [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
],
];

if ($exception->getPrevious() instanceof Exception) {
$log += [
'previous' => [
'message' => $exception->getPrevious()->getMessage(),
'exception' => get_class($exception->getPrevious()),
'file' => $exception->getPrevious()->getFile(),
'line' => $exception->getPrevious()->getLine(),
],
];
}

$this->logger->error(json_encode($log));
}
}

services:
App\Event\ExceptionListener:
arguments:
- '@monolog.logger.api'
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }

CountryController


declare(strict_types=1);

namespace App\Controller;

use App\Service\CountryServiceInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

/**
* @Route("/countries")
*/
class CountryController
{
private $countryService;

public function __construct(CountryServiceInterface $countryService)
{
$this->countryService = $countryService;
}

/**
* @Route("/{id}")
* @Method({"GET"})
*/
public function getOneById(int $id): Response
{
$country = $this->countryService->getOneById($id);

return new JsonResponse($country, Response::HTTP_OK);
}

/**
* @Route("/{id}")
* @Method({"PATCH"})
*/
public function updateOneById(int $id): Response
{
$this->countryService->updateOneById($id);

return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

CountryServiceInterface


declare(strict_types=1);

namespace App\Service;

interface CountryServiceInterface
{
public function getOneById(int $id);

public function updateOneById(int $id);
}

CountryService


declare(strict_types=1);

namespace App\Service;

use App\Entity\Country;
use App\Exception\CountryException;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;

class CountryService implements CountryServiceInterface
{
public function getOneById(int $id)
{
$country = 'I am not a Country object';
if (!$country instanceof Country) {
throw new CountryException(
sprintf('Country "%d" was not found.', $id),
Response::HTTP_NOT_FOUND
);
}

return $country;
}

public function updateOneById(int $id)
{
try {
$this->internalException();
} catch (RuntimeException $e) {
throw new CountryException(
sprintf('Country "%d" state was changed before.', $id),
Response::HTTP_CONFLICT,
$e
);
}
}

private function internalException()
{
throw new RuntimeException('An internal error was encountered.');
}
}

Tests


This is what end user gets.


$ curl -i -X GET http://192.168.99.20:81/api/v1/countries/11

HTTP/1.1 404 Not Found
Server: nginx/1.6.2
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Fri, 06 Jul 2018 22:14:07 GMT

Country "11" was not found.

$ curl -i -X PATCH http://192.168.99.20:81/api/v1/countries/1 -H 'Content-Type: application/json' -d '{"code":"gb"}'

HTTP/1.1 409 Conflict
Server: nginx/1.6.2
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, private
Date: Fri, 06 Jul 2018 22:14:56 GMT

Country "1" state was changed before.

This is what our logs looks like.


[2018-07-07 09:39:35] api.ERROR: {"code":404,"message":"Country \"1\" was not found.","called":{"file":"\/srv\/www\/api\/src\/Controller\/CountryController.php","line":31},"occurred":{"file":"\/srv\/www\/api\/src\/Service\/CountryService.php","line":18}} [] []
[2018-07-07 09:39:48] api.ERROR: {"code":409,"message":"Country \"1\" state was changed before.","called":{"file":"\/srv\/www\/api\/src\/Controller\/CountryController.php","line":42},"occurred":{"file":"\/srv\/www\/api\/src\/Service\/CountryService.php","line":32},"previous":{"message":"An internal error was encountered.","exception":"RuntimeException","file":"\/srv\/www\/api\/src\/Service\/CountryService.php","line":42}} [] []

Clearer version.


{
"code": 404,
"message": "Country \"1\" was not found.",
"called": {
"file": "\/srv\/www\/api\/src\/Controller\/CountryController.php",
"line": 31
},
"occurred": {
"file": "\/srv\/www\/api\/src\/Service\/CountryService.php",
"line": 18
}
}

{
"code": 409,
"message": "Country \"1\" state was changed before.",
"called": {
"file": "\/srv\/www\/api\/src\/Controller\/CountryController.php",
"line": 42
},
"occurred": {
"file": "\/srv\/www\/api\/src\/Service\/CountryService.php",
"line": 32
},
"previous": {
"message": "An internal error was encountered.",
"exception": "RuntimeException",
"file": "\/srv\/www\/api\/src\/Service\/CountryService.php",
"line": 42
}
}