DEV Community

Cover image for How to Handle Validation Errors in Symfony the Right Way
Mohammad Oveisi
Mohammad Oveisi

Posted on

How to Handle Validation Errors in Symfony the Right Way

Introduction

Handling validation errors is something every Symfony API needs — yet it often ends up messy, repetitive, and inconsistent.

Modern Symfony makes request validation elegant with DTOs and attributes like #[MapRequestPayload].

But when validation fails, the default error-handling story still isn’t great.

In this post, I’ll show:

  • The common problem with validation errors in Symfony APIs
  • Why the default behavior doesn’t scale well
  • A clean, modern approach to solving it
  • How to get consistent JSON validation responses with zero boilerplate

The Common Problem

A typical Symfony API controller today might look like this:

#[Route('/api/products', methods: ['POST'])]
public function create(
    #[MapRequestPayload] CreateProductDto $dto
): JsonResponse {
    // business logic
}
Enter fullscreen mode Exit fullscreen mode

This looks clean — and it is.

But when validation fails, Symfony throws a ValidationFailedException.

What you often get out of the box:

  • Very verbose error structures
  • Debug-oriented responses
  • Inconsistent formats depending on where validation happens
{
  "type": "https://symfony.com/errors/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "name: Name is required\nname: Product name must be at least 3 characters long\nprice: Price must be zero or positive\nstatus: Status must be one of: \"active\", \"inactive\", \"draft\"",
  "violations": [
    {
      "propertyPath": "name",
      "title": "Name is required",
      "template": "Name is required"
    }
  ],
  "trace": ["... stack trace omitted ..."]
}
Enter fullscreen mode Exit fullscreen mode

To fix this, many teams end up:

  • Catching exceptions manually
  • Writing custom error mappers
  • Repeating the same logic in every controller

That’s not ideal.


What We Actually Want

For APIs, validation errors should be:

  • JSON-only
  • Consistent across all endpoints
  • Easy for frontend clients to consume
  • Free of Symfony internals
  • Automatically handled
{
  "errors": {
    "name": [
      "Product name is required",
      "Product name must be at least 3 characters long"
    ],
    "price": [
      "Price must be zero or positive"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

No try/catch blocks.

No duplicated code.

No controller-level logic.


A Clean Approach

Let validation fail naturally — but intercept the exception once and format it properly.

This is exactly what Symfony Validation Response Bundle does.

It listens for validation failures coming from:

  • #[MapRequestPayload]
  • #[MapQueryString]
  • #[MapUploadedFile]

And transforms them into clean JSON responses automatically.


Installation

composer require soleinjast/symfony-validation-response
Enter fullscreen mode Exit fullscreen mode

No configuration required to get started.


Example in Action

DTO with Validation

final class CreateProductDto
{
    public function __construct(
        #[Assert\NotBlank(message: 'Product name is required')]
        #[Assert\Length(min: 3)]
        public string $name,

        #[Assert\PositiveOrZero]
        public int $price,

        #[Assert\Choice(['active', 'inactive', 'draft'])]
        public string $status = 'draft'
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Controller

#[Route('/api/products', methods: ['POST'])]
public function create(
    #[MapRequestPayload] CreateProductDto $dto
): JsonResponse {
    return $this->json(['message' => 'Created'], 201);
}
Enter fullscreen mode Exit fullscreen mode

Invalid Request → Automatic Response

{
  "errors": {
    "name": [
      "Product name is required"
    ],
    "price": [
      "This value should be either positive or zero."
    ],
    "status": [
      "The value you selected is not a valid choice."
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Returned with HTTP 422 (Unprocessable Entity).


Configuration: Validation Response Templates

Create the following file:

config/packages/validation_response.yaml
Enter fullscreen mode Exit fullscreen mode

The bundle ships with two predefined templates:

  • simple (default)
  • rfc7807 (Problem Details for HTTP APIs)

Simple Format (Default)

validation_response:
    format: 'simple'
    status_code: 422
Enter fullscreen mode Exit fullscreen mode
{
  "errors": {
    "email": [
      "Invalid email format"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

RFC 7807 Format (Problem Details)

validation_response:
    format: 'rfc7807'
    rfc7807:
        type: 'https://api.example.com/errors/validation'
        title: 'Validation Failed'
Enter fullscreen mode Exit fullscreen mode
{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "Validation errors detected",
  "violations": [
    {
      "field": "email",
      "message": "Invalid email format"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Note: When using rfc7807, the HTTP status code is always 422 as defined by the specification.


Defining Your Own Preferred Formatter

If neither simple nor rfc7807 fits your needs, you can define your own custom formatter.

Step 1: Create a Custom Formatter

namespace App\Validation;

use Soleinjast\ValidationResponse\Formatter\FormatterInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;

final class ApiValidationFormatter implements FormatterInterface
{
    public function format(ConstraintViolationListInterface $violations): array
    {
        $errors = [];

        foreach ($violations as $violation) {
            $errors[] = [
                'field' => $violation->getPropertyPath(),
                'message' => $violation->getMessage(),
                'code' => $violation->getCode(),
            ];
        }

        return [
            'success' => false,
            'error_count' => count($errors),
            'errors' => $errors,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Registering the Formatter in services.yaml

After creating your formatter, you need to register it and override the bundle’s listener.

# config/services.yaml
services:
    # Register your custom formatter
    App\Validation\ApiValidationFormatter: ~

    # Override the default validation exception listener
    Soleinjast\ValidationResponse\EventListener\ValidationExceptionListener:
        arguments:
            $formatter: '@App\Validation\ApiValidationFormatter'
            $statusCode: 422
        tags:
            - { name: kernel.event_subscriber }
Enter fullscreen mode Exit fullscreen mode

How This Works

  • The bundle uses a single event subscriber to intercept validation exceptions
  • By redefining it in services.yaml, Symfony replaces the default formatter
  • Your formatter is now used everywhere:
    • HTTP validation errors
    • #[MapRequestPayload]
    • #[MapQueryString]
    • #[MapUploadedFile]
    • CLI validation testing

Bonus: CLI Validation Testing

php bin/console validation:test CreateProductDto '{"name":"","price":-10}'
Enter fullscreen mode Exit fullscreen mode

This command uses the same formatter and configuration as your API.


Design Philosophy

This bundle is intentionally:

  • Symfony-native
  • Minimal and explicit
  • Zero-configuration by default
  • Focused on one responsibility only

It doesn’t replace Symfony validation — it simply finishes the job cleanly.


When Should You Use This?

  • You build JSON APIs
  • You use DTO-based validation
  • You want consistent, frontend-friendly errors
  • You don’t want validation logic in controllers

Links


Final Thoughts

Validation errors are part of every API — but handling them shouldn’t be painful.

With a small, focused solution, you can keep your controllers clean and your API responses predictable.

Happy coding 🚀

Mohammad Oveisi

Top comments (1)

Collapse
 
yektaroustaei profile image
YektaRoustaei

Useful validation