Errors and Exceptions

Throw an exception and Symfony turns it into a response; HttpExceptionInterface decides the status code.

Syntaxthrow new HttpException(int $statusCode, string $message = '', ?\Throwable $previous = null, array $headers = [], int $code = 0)

In Symfony you do not build error responses by hand. You throw, and the framework converts the exception into a response. An uncaught exception stops the controller, Symfony dispatches the kernel.exception event, and whatever comes out is sent to the client.

The status code comes from the exception. Anything implementing HttpExceptionInterface — which requires getStatusCode(): int and getHeaders(): array — supplies its own. Everything else is a 500. HttpException is the general implementation; its constructor is (int $statusCode, string $message = '', ?\Throwable $previous = null, array $headers = [], int $code = 0). Named subclasses exist for the common codes: NotFoundHttpException, BadRequestHttpException, AccessDeniedHttpException, ConflictHttpException, TooManyRequestsHttpException.

Shorthand and longhand

throw $this->createNotFoundException('No such invoice.') is the same as throw new NotFoundHttpException('No such invoice.'). The helper only builds the object.

What the user sees

It depends on the environment. In dev you get the exception page: message, stack trace, logs. In prod you get a generic page, and the message is logged rather than displayed. Override those pages by adding templates under templates/bundles/TwigBundle/Exception/ — error404.html.twig for one code, error.html.twig for the rest. To see them while developing, enable the /_error preview routes and open /_error/404.

What trips people up

The exception message is shown in dev and hidden in prod, so never put anything a user needs to read in it.

Example

Example · php
// src/Controller/InvoiceController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Routing\Attribute\Route;

final class InvoiceController extends AbstractController
{
    private const array INVOICES = [7 => 'INV-0007', 8 => 'INV-0008'];

    #[Route('/invoices/{id<\d+>}', name: 'app_invoice_show', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        if ($id > 9000) {
            // Any status code, with headers for the response Symfony builds.
            throw new HttpException(
                Response::HTTP_SERVICE_UNAVAILABLE,
                'Billing archive is offline.',
                headers: ['Retry-After' => '120'],
            );
        }

        $number = self::INVOICES[$id] ?? null;

        if (null === $number) {
            // Shorthand. The helper builds the exception; you still throw it.
            throw $this->createNotFoundException(sprintf('Invoice %d not found.', $id));
            // Longhand:
            // throw new NotFoundHttpException(sprintf('Invoice %d not found.', $id));
        }

        return $this->json(['id' => $id, 'number' => $number]);
    }
}

When to use it

  • Answer 404 when a URL's identifier matches nothing, instead of returning an empty page.
  • Return 503 with Retry-After while a dependency is down.
  • Give an application-specific failure its own status code wherever it is thrown.
  • Design and preview the 404 and 500 pages real visitors will see.

More examples

A domain exception that carries its own status

Implementing the interface lets code deep in your application throw a meaningful exception without importing anything HTTP-shaped at the call site, while the framework still answers 429 with a Retry-After header. Both methods are required by the interface; omitting getHeaders() is a fatal error, not a warning. Use this when the same condition should produce the same status wherever it is detected.

Example · php
// src/Exception/QuotaExceededException.php
declare(strict_types=1);

namespace App\Exception;

use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

final class QuotaExceededException extends \RuntimeException implements HttpExceptionInterface
{
    public function __construct(private readonly int $retryAfterSeconds)
    {
        parent::__construct('Monthly quota exceeded.');
    }

    public function getStatusCode(): int
    {
        return 429;
    }

    /**
     * @return array<string, string>
     */
    public function getHeaders(): array
    {
        return ['Retry-After' => (string) $this->retryAfterSeconds];
    }
}

A production error page

The directory name is load-bearing: templates/bundles/TwigBundle/Exception/error404.html.twig overrides the bundle's own 404 page, and error.html.twig covers every other HTML status. These templates render in prod, where the exception page is gone, so they are the only thing a real visitor sees. Keep them free of database calls, because they also render when the database is what failed.

Example · twig
{# templates/bundles/TwigBundle/Exception/error404.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Page not found{% endblock %}

{% block body %}
    <h1>We could not find that page</h1>

    {# status_code and status_text are always provided. #}
    <p>{{ status_code }} {{ status_text }}</p>

    <p>
        Check the address, or
        <a href="{{ path('app_article_index') }}">return to the article list</a>.
    </p>

    {# The `exception` variable also exists, but its message can leak internals.
       Do not print exception.message on a production page. #}
{% endblock %}

Previewing error pages in dev

With this in place, /_error/404 renders your 404 template and /_error/500 your generic one, so you can style them without breaking the application on purpose. The when@dev prefix limits the routes to the dev environment; exposing them in production would hand visitors a page generator. Append a format to check non-HTML output, for example /_error/404.json.

Example · yaml
# config/routes/framework.yaml
when@dev:
    _errors:
        resource: '@FrameworkBundle/Resources/config/routing/errors.php'
        type: php
        prefix: /_error

Discussion

  • Be the first to comment on this lesson.