Errors and Problem Details
One error body for every failure: RFC 9457 problem details, validation mapped to 422, and nothing internal in the response.
#[AsEventListener(event: 'kernel.exception', priority: 10)]Clients need errors they can parse. If a 404 returns HTML, a 422 returns a sentence and a 500 returns a stack trace, every caller ends up writing guesswork.
Problem details
RFC 9457, which replaced RFC 7807, defines one JSON shape for HTTP errors and the media type application/problem+json that carries it. Its members are type, a URI naming the kind of problem; title, a short human-readable label for that kind; status, the status code repeated inside the body; detail, what went wrong this time; and instance, a URI for this one occurrence. You may add members of your own, which is where a list of field errors goes.
Symfony ships ProblemNormalizer. It turns an uncaught exception into type, title, status and detail, and adds class and trace only when debug is on. The JSON error renderer uses it, so with the Serializer installed a failing API request already returns a parseable body.
Validation failures
#[MapRequestPayload] reports a failure by throwing an HttpException — 422 by default — whose previous exception is a ValidationFailedException. The violation messages are joined into one string. To answer field by field instead, listen on the kernel.exception event, look for that previous exception, and add your own violations member.
Never leak internals
With debug off, detail falls back to the status text rather than the exception message. Keep it that way. Messages contain table names, file paths and query fragments. Log the detail; return the status.
Example
// src/EventListener/ApiProblemListener.php
declare(strict_types=1);
namespace App\EventListener;
use App\Api\ViolationFormatter;
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;
use Symfony\Component\Validator\Exception\ValidationFailedException;
#[AsEventListener(event: 'kernel.exception', priority: 10)]
final class ApiProblemListener
{
public function __construct(private readonly ViolationFormatter $formatter)
{
}
public function __invoke(ExceptionEvent $event): void
{
if (!str_starts_with($event->getRequest()->getPathInfo(), '/api/')) {
return; // HTML pages keep the normal error page.
}
$throwable = $event->getThrowable();
// Only HTTP exceptions carry a status; anything else is a 500.
$status = $throwable instanceof HttpExceptionInterface
? $throwable->getStatusCode()
: Response::HTTP_INTERNAL_SERVER_ERROR;
$problem = [
'type' => 'about:blank',
'title' => Response::$statusTexts[$status] ?? 'Error',
'status' => $status,
];
// The payload resolver wraps the failure, so the violations are one
// level down the exception chain.
$previous = $throwable->getPrevious();
if ($previous instanceof ValidationFailedException) {
$problem['violations'] = $this->formatter->format($previous->getViolations());
}
$response = new JsonResponse($problem, $status);
$response->headers->set('Content-Type', 'application/problem+json');
// Setting a response stops the exception reaching the error renderer.
$event->setResponse($response);
}
}
// POST /api/articles with a blank title returns 422, the content type
// application/problem+json, and a body holding type, title, status and a
// violations array with one entry per failed field. No message from the
// exception reaches the client.When to use it
- Give every failing API request one parseable body, whatever threw.
- Turn a 422 from an input DTO into a list of field names and messages a form can display.
- Map a domain rule such as "already published" to 409 without mentioning HTTP in the service.
- Make sure a production 500 says nothing about tables, paths or SQL.
More examples
A status code on a domain exception
This keeps the HTTP vocabulary out of your services without losing it. A service throws a domain exception that reads like the business rule it protects; the attribute maps it to 409 once, for every endpoint that can hit the rule. <code>#[WithLogLevel]</code> matters in production: an expected conflict logged at error level trains people to ignore error logs.
// src/Domain/Exception/ArticleAlreadyPublished.php
declare(strict_types=1);
namespace App\Domain\Exception;
use Psr\Log\LogLevel;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;
use Symfony\Component\HttpKernel\Attribute\WithLogLevel;
// The service that throws this knows nothing about HTTP. The attributes
// tell the kernel which status to answer and how loudly to log it.
// Both attributes were added in Symfony 6.3.
#[WithHttpStatus(Response::HTTP_CONFLICT)]
#[WithLogLevel(LogLevel::WARNING)]
final class ArticleAlreadyPublished extends \RuntimeException
{
}Violations as a problem member
The shape of this array is part of your API, so it belongs in a class of its own rather than inline in a listener. <code>getPropertyPath()</code> returns the path to the offending value, including nested paths like <code>author.email</code> and collection indexes like <code>tags[0]</code>, which is exactly what a client needs to attach the message to a form field. Cast the message: it may be an object that stringifies.
// src/Api/ViolationFormatter.php
declare(strict_types=1);
namespace App\Api;
use Symfony\Component\Validator\ConstraintViolationListInterface;
final class ViolationFormatter
{
/**
* @return list<array{field: string, message: string}>
*/
public function format(ConstraintViolationListInterface $violations): array
{
$fields = [];
foreach ($violations as $violation) {
$fields[] = [
'field' => $violation->getPropertyPath(),
'message' => (string) $violation->getMessage(),
];
}
return $fields;
}
}
// Output: for a blank title and an out-of-range state, format() returns
// [
// ['field' => 'title', 'message' => 'This value should not be blank.'],
// ['field' => 'state', 'message' => 'The value you selected is not a valid choice.'],
// ]Registering the listener the long way
The <code>#[AsEventListener]</code> attribute on the class and this tag in configuration do the same job: they add the service to the list the dispatcher calls for that event. The attribute is the shorthand and keeps the registration next to the code; the YAML form is what you need when the listener lives in a package you cannot edit, or when the same class is registered twice with different priorities. A higher priority runs earlier, which is how your listener gets the exception before anything that would render a page.
# config/services.yaml
services:
App\EventListener\ApiProblemListener:
tags:
- { name: kernel.event_listener, event: kernel.exception, priority: 10 }
Discussion