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 (6)

Collapse
 
frong1979 profile image
Marta

Great read! Handling validation errors properly in Symfony is crucial for both UX and data integrity. I like how you emphasized using the Validator component and custom error messages—it really keeps the code clean and user-friendly. Also, integrating error handling with forms and API responses makes a huge difference in maintainability.

Collapse
 
xwero profile image
david duymelinck • Edited

Why would someone prefer an extra library over a application specific solution?
The library is basically an opinionated custom solution. So why would the opinion of the library be so universal that it fulfills the needs of most applications?

The red flag for me is that you added a trace key to the default output. The smart people of Symfony aren't going to expose the stack trace because it is a security risk knowing that information.
That the output will not be useful for everyone, is because it is a framework. They provide a sensible response that is easy to override.

Collapse
 
mohammad_oveisi_9625d74d1 profile image
Mohammad Oveisi

Thanks for the feedback — a couple of clarifications might help.

The goal of the library isn’t to replace application-specific logic, but to handle a cross-cutting concern consistently: formatting validation errors for JSON APIs. This is similar to why we use Monolog, serializers, or middleware instead of re-implementing those concerns per project.

Regarding being “opinionated”: the bundle provides sensible defaults, but everything is fully overridable via standard Symfony service overriding.

About the trace key — that output comes from Symfony’s default debug exception handling, not from this library. In fact, one of the motivations for the bundle is to avoid exposing debug-oriented structures (including stack traces) in API responses and return clean, predictable JSON instead.

If an application prefers a bespoke solution, that’s totally valid. This library is simply an option for teams that want consistency without repeating the same glue code across projects.

Collapse
 
xwero profile image
david duymelinck

To be clear I'm not saying it is a bad library. The point I'm trying to make is that with the flexibility of the framework adding an extra library is not needed, even if you want a reusable solution.

About the trace key — that output comes from Symfony’s default debug exception handling, not from this library.

Read my comment again. I never linked the stack trace with the library

repeating the same glue code across projects

Isn't all custom code glue code? Even the library.

This sentence feels like you know better than all the other developers that use Symfony.

Thread Thread
 
mohammad_oveisi_9625d74d1 profile image
Mohammad Oveisi

Thanks for clarifying — I think we’re actually closer in opinion than it might look.

I agree that Symfony is flexible enough to solve this at the application level, and for many teams that’s absolutely the right choice. The library isn’t meant to suggest otherwise, nor to replace application-specific decisions.

The motivation here is simply reuse and consistency for teams that already find themselves solving the same validation-response problem across multiple projects. In that context, extracting the solution into a small, focused bundle can be a pragmatic trade-off — not a claim that this is “the” correct way.

On the “glue code” point: you’re right in a broad sense — everything is glue at some level. The distinction I’m trying to make is between duplicated, per-project glue and a shared, explicit abstraction that teams can choose to adopt or ignore.

If a team prefers keeping this logic inline or bespoke per application, that’s completely valid. This library is just one option for those who value a reusable, centralized approach.

Collapse
 
yektaroustaei profile image
YektaRoustaei

Useful validation