Returning Validation Errors in Symfony APIs as Problem Details
AI generated
{ }
GET
REST API · Symfony · RFC 7807 · Validation
Returning Validation Errors in Symfony APIs
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.

12 min read RFC 7807 · application/problem+json · ConstraintViolation · API Platform Symfony 6.x / 7.x · PHP 8.2+

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?
An IETF standard for machine-readable JSON error responses. Content type: application/problem+json. Fields: type, title, status, detail, instance, plus custom extensions such as violations.
2Why 422 instead of 400 for validation errors?
400 = syntactically invalid request. 422 = syntactically correct but semantically invalid. Validation errors are semantic errors, 422 Unprocessable Entity is the more precise response.
3How do I force JSON instead of HTML in Symfony?
In the 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?
Yes, from API Platform 3 onward with use_symfony_listeners: true this is the default. Customization via decorating the ValidationExceptionNormalizer.
5How do I document Problem Details in OpenAPI?
Define a schema in 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?
Symfony uses the validators.en.yaml catalog. In the normalizer, inject TranslatorInterface and translate messages with the current locale.
7Can I add custom fields?
Yes, RFC 7807 allows arbitrary extension fields. Useful: 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?
With WebTestCase: check the response status is 422, test the content type for application/problem+json, assert the violations array for the correct propertyPath values.
10Problem Details for all exception types?
Yes, a central listener maps all exception types: 404 for NotFound, 403 for AccessDenied, 409 for conflict errors. Consistency across the whole API.