Turning Exceptions into Responses

Catch a throwable on kernel.exception and return JSON for API routes, HTML elsewhere, without leaking internals in production.

Syntaxpublic function __invoke(ExceptionEvent $event): void

HTTP has no concept of an exception. When a throwable escapes a controller, the kernel catches it and dispatches kernel.exception with an ExceptionEvent. Listeners may call setResponse(); if none does, Symfony's own ErrorListener renders an error page. That is the only reason an uncaught exception produces a 500 rather than a blank screen.

Status codes

Any throwable implementing HttpExceptionInterface carries the status itself, through getStatusCode(), plus headers through getHeaders() — Allow on a 405, Retry-After on a 429. NotFoundHttpException, BadRequestHttpException and the rest of that family implement it. Anything else is a 500, because an unplanned failure is a server fault.

One application, two formats

A browser wants an HTML page; a JSON client wants a JSON body it can parse. The practical pattern is a listener that handles only what it recognises — for example requests under /api/ — and returns early for everything else, leaving the HTML path to Symfony.

What trips people up

Exception messages are written for developers. They contain table names, file paths, sometimes credentials from a connection string. Decide what to show from kernel.debug, not from the exception: client errors can repeat their message, server errors get a fixed string, and the real detail goes to the log. A stack trace never goes in a response body.

Example

Example · php
<?php
// src/EventListener/ApiExceptionListener.php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

#[AsEventListener]
final class ApiExceptionListener
{
    public function __construct(
        // kernel.debug is true in dev, false in prod.
        #[Autowire(param: 'kernel.debug')]
        private readonly bool $debug,
    ) {
    }

    public function __invoke(ExceptionEvent $event): void
    {
        // Not an API request: do nothing, and Symfony's ErrorListener
        // renders the HTML error page as usual.
        if (!str_starts_with($event->getRequest()->getPathInfo(), '/api/')) {
            return;
        }

        $throwable = $event->getThrowable();

        $status = $throwable instanceof HttpExceptionInterface
            ? $throwable->getStatusCode()
            : Response::HTTP_INTERNAL_SERVER_ERROR;

        // 4xx messages are written for the client. 5xx messages are not.
        $message = $status < 500 || $this->debug
            ? $throwable->getMessage()
            : 'Internal Server Error';

        $response = new JsonResponse([
            'error' => ['status' => $status, 'message' => $message],
        ], $status);

        if ($throwable instanceof HttpExceptionInterface) {
            // add(), not replace(): replace() would drop the
            // Content-Type header JsonResponse just set.
            $response->headers->add($throwable->getHeaders());
        }

        // This also stops propagation.
        $event->setResponse($response);
    }
}

When to use it

  • Return a consistent JSON error body for an API while browser pages keep their HTML error pages.
  • Stop a database or configuration error from printing a file path to a visitor.
  • Send a Retry-After header with a 429 without writing header code in the controller.
  • Log the full exception where the response body only says "Internal Server Error".

More examples

Where the status codes come from

The controller side of the listener above. GET /api/comments/999 on a missing comment answers 404 with {"error":{"status":404,"message":"No comment with that id."}}, in dev and in prod alike, because 404 is below 500 and its message was written for the client. The rate-limited path answers 429 with a Retry-After: 30 header, carried from the exception by getHeaders(). Nothing in the controller mentions JSON formatting or status plumbing: it throws the right exception and the listener decides how that looks.

Example · php
<?php
// src/Controller/Api/CommentController.php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Service\CommentReader;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\Routing\Attribute\Route;

final class CommentController extends AbstractController
{
    public function __construct(
        private readonly CommentReader $reader,
    ) {
    }

    #[Route('/api/comments/{id<\d+>}', name: 'api_comment_show', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        $comment = $this->reader->find($id);

        if (null === $comment) {
            // Longhand. Status 404 travels with the exception.
            throw new NotFoundHttpException('No comment with that id.');
        }

        if ($this->reader->isRateLimited()) {
            // First argument is retryAfter, which becomes a Retry-After
            // header; second is the message; third is the previous
            // throwable.
            throw new TooManyRequestsHttpException(30, 'Slow down.');
        }

        return $this->json($comment);
    }

    // Shorthand for the same 404: createNotFoundException() returns a
    // NotFoundHttpException, it does not throw one, so you still throw it.
    //   throw $this->createNotFoundException('No comment with that id.');
}

Log first, respond second

The counterpart to a generic 500 body: the client gets "Internal Server Error", and the full message, class and stack trace go to the log, where an on-call engineer can read them. The priority is the load-bearing part. At the default 0 it would be a coin toss whether this runs at all, since the listener that builds the response stops propagation the moment it calls setResponse(). Filtering out sub-500 HTTP exceptions keeps a crawler hitting dead URLs from filling the error log.

Example · php
<?php
// src/EventSubscriber/ExceptionLogSubscriber.php
declare(strict_types=1);

namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;

final class ExceptionLogSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        // Higher than the listener that calls setResponse(), because
        // setResponse() stops propagation and would skip this method.
        return [
            KernelEvents::EXCEPTION => [['onException', 128]],
        ];
    }

    public function onException(ExceptionEvent $event): void
    {
        $throwable = $event->getThrowable();

        // A 404 is not an application failure; a 500 is.
        if ($throwable instanceof HttpExceptionInterface
            && $throwable->getStatusCode() < 500
        ) {
            return;
        }

        // The detail the response is not allowed to carry goes here.
        $this->logger->error($throwable->getMessage(), [
            'exception' => $throwable,
            'path' => $event->getRequest()->getPathInfo(),
        ]);
    }
}

The HTML side

Non-API requests were deliberately left alone by the listener, which hands them to Symfony's error handling. Two levers shape that path: error_controller replaces the controller that renders the page, and templates under templates/bundles/TwigBundle/Exception/ override the markup per status code, for example error404.html.twig. In dev you get the detailed exception page instead, because debug mode substitutes it. Verify the production page by running with APP_ENV=prod and APP_DEBUG=0 locally, since that is the only way to see what a real visitor sees.

Example · yaml
# config/packages/framework.yaml
framework:
    # Replaces the controller Symfony's ErrorListener calls to render an
    # error page. It receives the original Throwable as $exception.
    error_controller: App\Controller\ErrorController::show

# config/packages/prod/framework.yaml
# In prod the generated page must never include a trace, so the template
# at templates/bundles/TwigBundle/Exception/error.html.twig prints the
# status code and a human sentence only.

Discussion

  • Be the first to comment on this lesson.