I had to build my own Symfony validation bundle because no existing one fits my requirements

Published: (March 11, 2026 at 06:02 PM EDT)
7 min read
Source: Dev.to

Source: Dev.to

Contents

Long story short

I created a bundle for request validation with JSON Schema because no existing “schema‑first” validator fit my requirements.
Now I just attach a JSON file to a route and get everything at once: validation, DTO mapping, and OpenAPI documentation from a single source of truth.

  • Repo:
  • Docs:

The Problem

Most validation solutions that can generate API documentation from code (in the Symfony world I mostly mean FOSRestBundle and API Platform) assume that your business logic is:

  1. Well defined and relatively stable
  2. Close to a classic CRUD model (or CRUD with small deviations)
  3. Exposed via clean, REST‑style endpoints that you fully control

In other words, they assume your application defines the contract and the outside world adapts to it.

In many real projects the opposite is true: the API contract is defined elsewhere (legacy frontend, external systems, partners), and you have to adapt to that contract. That is where problems start.

Example payload

{
  "type": "company",
  "user": {
    "name": "John",
    "email": "john@example.com",
    "company": {
      "name": "Acme"
    }
  }
}

Validation rules

  • If type = "company" then user.company.name is required.
  • If type = "person" then user.company must be absent.

Is this the most elegant API design? Probably not. But imagine a company with 2 000 people and a frontend written years ago that sends exactly this structure. You cannot just redesign everything because you do not like the shape of the JSON.

“Ideal” Symfony validation (doesn’t work for the conditional rule)

class UserDto
{
    #[Assert\NotBlank]
    public string $name;

    #[Assert\Valid]
    public ?CompanyDto $company;
}

This does not express the conditional logic “company.name is required when type = company”. To implement it you usually end up with one of the following:

  • A custom constraint with its own validator.
  • Manual validation logic in the controller or a service.
  • A custom normalizer/denormalizer with additional checks.

Extra‑property handling

You also need to forbid properties that are not defined in UserDto. In newer Symfony versions you can write something like:

#[MapRequestPayload(
    serializationContext: ['allow_extra_attributes' => false]
)]
#[MapQueryString(
    serializationContext: ['allow_extra_attributes' => false]
)]

Whether this works for query parameters depends on the exact types and context. For headers this approach does not work at all.

Why be strict?
Ignoring unknown parameters can lead to subtle bugs. For example, a query parameter offset is renamed to page. Clients still sending offset will get the wrong page, and the issue may be hard to trace. With strict validation the client receives a clear error about an unknown parameter, making the problem visible immediately.

A clear, precise error message is part of the API’s UX: clients should know exactly what went wrong, and the backend team benefits from fewer support tickets and less time spent guessing the cause.

The Idea

The kind of validation I needed has existed for years, just not as a typical Symfony validator. I am talking about the JSON Schema standard:

JSON Schema is a declarative language for defining structure and constraints for JSON data. It is designed exactly for problems like the ones above (and much more complex ones):

  • Conditional rules based on other fields.
  • Nested, deeply structured data.
  • Strict control over allowed and forbidden properties.

Idea: Instead of forcing the API contract into DTO classes and annotations, let Symfony validate incoming requests against a JSON Schema that fully describes the contract. In other words, make Symfony request validation schema‑first, with JSON Schema as the single source of truth.

The Solution

The good news was that I did not need to implement JSON Schema myself. There is already a solid PHP implementation:

The library takes a valid JSON Schema and any input data, validates the data against the schema and either:

  • Returns the data (when everything is valid), or
  • Returns a structured list of validation errors.

From there, the rest was mostly integration work:

  1. Make it convenient to plug this validation into a Symfony project.
  2. Wire it into the request lifecycle.
  3. Provide a way to map validated data into DTOs when needed.
  4. Integrate with Nelmio so that OpenAPI documentation is generated from the same schemas.

Below I will show a couple of short examples.

Quick Examples

The Schema

{
    "$schema": "https://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "query": {
            "type": "object",
            "properties": {},
            "additionalProperties": true
        },
        "headers": {
            "type": "object",
            "properties": {
                "authorization": {
                    "type": "string",
                    "description": "Bearer token for authentication",
                    "pattern": "^Bearer .+",
                    "example": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ6..."
                },
                "x-api-version": {
                    "type": "string",
                    "description": "API version",
                    "enum": ["v1", "v2"],
                    "example": "v1"
                },
                "content-type": {
                    "type": "string",
                    "description": "Request content type",
                    "enum": ["application/json"],
                    "example": "application/json"
                }
            },
            "additionalProperties": true
        },
        "body": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 100,
                    "description": "User's full name",
                    "example": "Jane Smith"
                },
                "email": {
                    "type": "string",
                    "format": "email",
                    "description": "User's email address",
                    "example": "john.doe@example.com"
                },
                "age": {
                    "type": "integer",
                    "minimum": 21,
                    "maximum": 100,
                    "description": "User's age (optional)",
                    "example": 30
                }
            },
            "required": ["name", "email"],
            "additionalProperties": false
        }
    }
}

Example 1 – Validation using the built‑in request object

#[OA\Post(
    operationId: 'validateUser',
    summary: 'Validate user',
)]
#[Route('/user', name: '_example_validation_user', methods: ['POST'])]
public function validateUser(
    #[MapRequest('./user-create.json')] ValidatedRequest $request
): JsonResponse {
    $payload = $request->getPayload();
    $body    = $payload->getBody();

    return $this->json([
        'success' => true,
        'message' => 'User data is valid',
        'data'    => [
            'name'  => $body->name,
            'email' => $body->email,
            'age'   => $body->age ?? null,
        ],
        'example' => 'This uses ValidatedRequest (standard way)',
    ], 200);
}

Example 2 – Validation using a custom DTO (via ValidatedDtoInterface)

#[OA\Post(
    operationId: 'createProfile',
    summary: 'Create profile',
)]
#[Route('/profile', name: '_example_validation_profile', methods: ['POST'])]
public function createProfile(
    #[MapRequest('./user-create.json')] UserApiDtoRequest $profile
): JsonResponse {
    return $this->json([
        'success' => true,
        'message' => 'Profile created successfully',
        'profile' => [
            'name'  => $profile->name,
            'email' => $profile->email,
            'age'   => $profile->age,
        ],
        'note' => sprintf(
            'This demonstrates DTO auto‑injection: MapRequestResolver calls %s::fromPayload() automatically',
            UserApiDtoRequest::class
        ),
    ], 201);
}

What’s the result?

  • Focused solution – instead of reinventing the wheel, the bundle fills a specific gap that existing Symfony tools do not cover well (schema‑first request validation).
  • Less duplication – you no longer have to mirror the same rules in DTO constraints, controllers, and OpenAPI annotations.
  • Automatic sync – Nelmio builds documentation from the same JSON Schema files that are used for validation, so your docs always match the real behaviour.
  • Contract‑centric design – the entire API contract lives in clean JSON files rather than being scattered across PHP attributes and classes.

If you need a way to validate requests without creating a large number of redundant DTO classes and annotations, this bundle is designed for that use case.

  • Repo:
  • Docs:

The Full Story

This article is the short, focused version of the story: what the bundle does and how to start using it. If you want the long version with all the scars and details, I wrote a separate, much bigger post.

In that post I go through:

  • how the bundle was born in a very messy real project, not in a green‑field demo
  • why classic DTO + Assertions validation did not survive 500 + routes
  • how JSON Schema became the single contract language for backend, frontend, and docs
  • how the bundle glues Symfony, Opis JSON Schema and Nelmio together

You can read the full story here (starting from The Full Story section):

[thing#the-full-story](https://outcomer.hashnode.dev/symfony-bundle-that-validates-anything-and-everything#the-full-story)
0 views
Back to Blog

Related posts

Read more »