as Problem Details
Validation errors that arrive as a raw HTML page or an unstructured JSON array cost frontend teams time. RFC 7807 Problem Details define a machine-readable format that Symfony can generate from the ConstraintViolationList with little effort, consistent, documentable and without framework lock-in.
Table of Contents
- 1. The problem with unstructured validation errors
- 2. RFC 7807 Problem Details, what the standard requires
- 3. Symfony Validator and ConstraintViolationList
- 4. Custom exception listener for Problem Details
- 5. API Platform: Problem Details out of the box
- 6. Documenting Problem Details in OpenAPI
- 7. Comparison: error formats at a glance
- 8. Summary
- 9. FAQ
1. The problem with unstructured validation errors
A REST API that simply returns an HTTP 400 with an empty body or a generic "Validation failed" string on a bad request forces every client developer to figure out on their own which field is actually wrong. Worse, Symfony returns an HTML error page by default for unhandled exceptions when the Accept header is not set correctly. That produces frontend tickets like "API returns HTML" that are actually backend configuration issues.
The root problem is missing standardization. Every API invents its own error format: sometimes an array under the key errors, sometimes a flat object with field names as keys, sometimes a single message string. RFC 7807 Problem Details for HTTP APIs closes this gap: the format is machine-readable, extensible and supported by libraries in every common language. Symfony and API Platform already implement it, you just need to know the right levers to pull.
2. RFC 7807 Problem Details, what the standard requires
RFC 7807 (since refined by RFC 9457) defines a JSON format with the content type application/problem+json. The minimum requirement is an object with optional standard fields: type (a URI describing the error class), title (a human-readable short description), status (the HTTP status code as a number), detail (a concrete error description for this request) and instance (a URI for the specific request context). Every one of these properties is optional, but in practice type and status are effectively mandatory.
For validation errors the standard allows custom extension fields. The typical pattern in Symfony practice is an additional violations field as an array that describes each individual ConstraintViolation with propertyPath, message and an optional code. This structure can be evaluated programmatically right away: a React form can iterate the violations list and map each error to the correct input field without running regular expressions against error text.
{
"type": "https://mironsoft.de/errors/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The request contains 3 invalid fields.",
"instance": "/api/orders/create",
"violations": [
{
"propertyPath": "email",
"message": "This value is not a valid email address.",
"code": "bd79c0ab-ddba-46cc-a703-a7a4b08de310"
},
{
"propertyPath": "items[0].quantity",
"message": "This value should be greater than 0.",
"code": "ea4e51d1-3342-48bd-8f9e-67a7f287f7ee"
},
{
"propertyPath": "shippingAddress.postalCode",
"message": "This value is not a valid postal code.",
"code": null
}
]
}
3. Symfony Validator and ConstraintViolationList
Symfony's Validator component returns a ConstraintViolationList when validation fails. Each element of the list is a ConstraintViolationInterface with getPropertyPath(), getMessage() and getCode(). The task is to translate this list into the Problem Details format. The simplest approach in a controller is to trigger validation, throw a custom exception on failure, and catch that exception in a kernel listener to render it as a JsonResponse with the correct content type.
Since Symfony 6.3 there is the #[MapRequestPayload] attribute, which deserializes and validates automatically. If validation fails there, Symfony internally throws an HttpException that you can catch and remap. In older versions the validator service handles validation manually, and the controller decides itself whether to throw an exception.
<?php
// src/Exception/ValidationException.php
declare(strict_types=1);
namespace App\Exception;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Exception thrown when request payload validation fails.
* Carries the violation list for structured error serialization.
*/
final class ValidationException extends \RuntimeException
{
public function __construct(
private readonly ConstraintViolationListInterface $violations,
string $message = 'Validation Failed',
int $code = 422,
) {
parent::__construct($message, $code);
}
public function getViolations(): ConstraintViolationListInterface
{
return $this->violations;
}
}
// src/Controller/OrderController.php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\CreateOrderDto;
use App\Exception\ValidationException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Serializer\SerializerInterface;
final class OrderController extends AbstractController
{
public function __construct(
private readonly ValidatorInterface $validator,
private readonly SerializerInterface $serializer,
) {}
#[Route('/api/orders', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
/** @var CreateOrderDto $dto */
$dto = $this->serializer->deserialize(
$request->getContent(),
CreateOrderDto::class,
'json'
);
$violations = $this->validator->validate($dto);
if (count($violations) > 0) {
throw new ValidationException($violations);
}
// ... process order
return $this->json(['id' => 'new-order-id'], 201);
}
}
4. Custom exception listener for Problem Details
The central building block for consistent Problem Details responses is a kernel event listener on the kernel.exception event. There the exception is caught, checked to see whether it is a ValidationException, and the response is returned as application/problem+json. This listener is the only place in the entire project that knows how validation errors are serialized, no controller has to do this itself.
Important: the listener must be registered with a higher priority value than Symfony's built-in ExceptionListener so it runs first. For other exception types such as AccessDeniedException or NotFoundHttpException, the listener can return the same Problem Details structure, just without the violations field.
<?php
// src/EventListener/ProblemDetailsExceptionListener.php
declare(strict_types=1);
namespace App\EventListener;
use App\Exception\ValidationException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* Converts application exceptions into RFC 7807 Problem Details responses.
* Registered with high priority to run before Symfony's default exception handling.
*/
final class ProblemDetailsExceptionListener
{
private const PROBLEM_CONTENT_TYPE = 'application/problem+json';
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if (!$exception instanceof ValidationException) {
return;
}
$violations = [];
/** @var ConstraintViolationInterface $violation */
foreach ($exception->getViolations() as $violation) {
$violations[] = [
'propertyPath' => $violation->getPropertyPath(),
'message' => $violation->getMessage(),
'code' => $violation->getCode(),
];
}
$body = [
'type' => 'https://mironsoft.de/errors/validation-error',
'title' => 'Validation Failed',
'status' => Response::HTTP_UNPROCESSABLE_ENTITY,
'detail' => sprintf(
'The request contains %d invalid field(s).',
count($violations)
),
'instance' => $event->getRequest()->getRequestUri(),
'violations' => $violations,
];
$response = new JsonResponse($body, Response::HTTP_UNPROCESSABLE_ENTITY);
$response->headers->set('Content-Type', self::PROBLEM_CONTENT_TYPE);
$event->setResponse($response);
}
}
5. API Platform: Problem Details out of the box
API Platform already implements RFC 7807 in its core component. With use_symfony_listeners: true enabled (the default since API Platform 3), validation errors are automatically returned as application/problem+json, including a violations array with propertyPath and message. The format is compatible with the Hydra vocabulary and is automatically referenced in the OpenAPI documentation.
For customized error messages, the ValidationExceptionNormalizer can be decorated. Your own service takes over normalization and can, for example, insert translated messages from the Symfony Translator or replace internal constraint codes with publicly documented error codes. Decorating is preferable to overriding, because API Platform updates can change the built-in normalizer without breaking your own code.
# config/packages/api_platform.yaml
api_platform:
title: 'Mironsoft API'
version: '1.0.0'
formats:
json: ['application/json']
jsonld: ['application/ld+json']
jsonproblem: ['application/problem+json']
error_formats:
jsonproblem: ['application/problem+json']
jsonld: ['application/ld+json']
use_symfony_listeners: true
validator:
# Map Symfony constraint groups to API Platform operation groups
query_parameter_validation: true
exception_to_status:
# Map domain exceptions to HTTP status codes
App\Exception\OrderConflictException: 409
App\Exception\ResourceLockedException: 423
6. Documenting Problem Details in OpenAPI
A common mistake in OpenAPI documentation is omitting the error format from the specification. When 422 Unprocessable Entity is listed as a response but no schema is given for the body, clients cannot generate type-safe code. The Problem Details schema can be defined once in components/schemas and then referenced via $ref in every endpoint.
Particularly important is the content type application/problem+json as the key of the response body in the OpenAPI specification. Many API definitions declare a schema, but under application/json, which is technically incorrect for Problem Details and causes code generators to set the content type wrong.
# openapi/schemas/problem-details.yaml (included in openapi.yaml)
components:
schemas:
ProblemDetails:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
example: "https://mironsoft.de/errors/validation-error"
title:
type: string
example: "Validation Failed"
status:
type: integer
example: 422
detail:
type: string
example: "The request contains 2 invalid fields."
instance:
type: string
format: uri-reference
example: "/api/orders/create"
violations:
type: array
items:
$ref: '#/components/schemas/ConstraintViolation'
ConstraintViolation:
type: object
required: [propertyPath, message]
properties:
propertyPath:
type: string
example: "email"
message:
type: string
example: "This value is not a valid email address."
code:
type: string
nullable: true
example: "bd79c0ab-ddba-46cc-a703-a7a4b08de310"
# Usage in endpoint response:
# responses:
# '422':
# description: Validation Failed
# content:
# application/problem+json:
# schema:
# $ref: '#/components/schemas/ProblemDetails'
7. Comparison: error formats at a glance
There are several common approaches to API error formats. The choice has a direct effect on client parsing effort, documentability in OpenAPI and compatibility with tools such as Postman, Stoplight and API client generators.
| Format | Content type | Standardized | Fields per violation | Recommendation |
|---|---|---|---|---|
| RFC 7807 Problem Details | application/problem+json |
Yes (IETF) | propertyPath, message, code | Preferred |
| Symfony default (unconfigured) | text/html |
No | - | Not for APIs |
| Google API Design Guide | application/json |
De facto | field, description, reason | Acceptable |
| JSON:API Errors | application/vnd.api+json |
Yes (JSON:API) | source.pointer, title, detail, code | For JSON:API projects |
| Custom format | application/json |
No | Arbitrary | Avoid |
Mironsoft
REST API design, Symfony backend development and OpenAPI documentation
Want clean error handling in your Symfony APIs?
We implement RFC 7807 Problem Details in existing Symfony projects, document the schema in OpenAPI, and train your team in consistent API error handling.
Audit
Analysis of existing API error formats and identification of inconsistencies
Implementation
Problem Details listener, exception mapping and OpenAPI schema integration
Tests
PHPUnit tests for every error scenario and contract tests for API clients
8. Summary
RFC 7807 Problem Details is the only truly standardized way to communicate validation errors in REST APIs. In Symfony this is implemented with a ValidationException that carries the ConstraintViolationList, and a kernel event listener that turns the exception into an application/problem+json response. Anyone using API Platform gets the basic format for free, but still has to extend the OpenAPI documentation with the violations schema so code generators can produce type-safe client classes.
The most important principle: error formats must be centralized and consistent. Every endpoint that has its own error format increases the integration effort for every single client. A single, well-documented exception listener is the most maintainable solution for the whole project.
Problem Details in Symfony, the essentials at a glance
Content type
application/problem+json is mandatory, not application/json. Tools and clients recognize the format by this.
Violations array
Every ConstraintViolation as an object with propertyPath, message and code, machine-readable for frontend forms.
Central listener
Implement once in the kernel.exception listener, no controller should define error formats itself.
OpenAPI schema
ProblemDetails and ConstraintViolation as $ref components, define once, reference everywhere.
9. FAQ: Validation errors as Problem Details in Symfony
1What is RFC 7807 Problem Details?
application/problem+json. Fields: type, title, status, detail, instance, plus custom extensions such as violations.2Why 422 instead of 400 for validation errors?
3How do I force JSON instead of HTML in Symfony?
kernel.exception listener, check whether the request is an API path and always return JSON, regardless of the Accept header. Alternatively override framework.error_controller.4Does API Platform return Problem Details automatically?
use_symfony_listeners: true this is the default. Customization via decorating the ValidationExceptionNormalizer.5How do I document Problem Details in OpenAPI?
components/schemas, reference it via $ref in every 422 response. Content type must be application/problem+json, not application/json.6How do I translate constraint messages?
validators.en.yaml catalog. In the normalizer, inject TranslatorInterface and translate messages with the current locale.7Can I add custom fields?
traceId, timestamp, documentationUrl.8Difference between type and instance in Problem Details?
type describes the error class (stable URI, the same for all requests of this type). instance describes this specific request context (for example the request path).9How do I test Problem Details in PHPUnit?
WebTestCase: check the response status is 422, test the content type for application/problem+json, assert the violations array for the correct propertyPath values.