Skip to main content

Command Palette

Search for a command to run...

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

Problem: Symfony validation is class-first. Goal: Contract-first validation via JSON Schema.

Updated
20 min read
I had to build my own Symfony validation bundle because no existing one fits my requirements
D
Symfony developer. Tired of over-engineering. Building pragmatic tools for real-world APIs
💡
This article was significantly updated at 11th of March 2026: more examples, clearer story, and details on JSON Schema / Nelmio integration

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.

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:

  • Well defined and relatively stable

  • Close to a classic CRUD model (or CRUD with small deviations)

  • 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.

But in many real projects it is the opposite: the API contract is defined somewhere else (legacy frontend, external systems, partners), and you have to adapt to that contract. That is where problems start.

Here is a simplified example. The API expects this payload:

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

With the following 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 2000 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.

Now, what does the "ideal" Symfony validation setup suggest here?

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 this you usually end up with one of the following:

  • Custom constraint with its own validator

  • Manual validation logic in the controller or a service

  • Custom normalizer / denormalizer with additional checks

Then another question appears: how do you forbid extra properties that are not defined in UserDto? For a long time you simply could not. 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.

You might ask: why be so strict? Why not just ignore extra parameters? Because in real life this often leads to subtle bugs. A typical scenario: you had a query parameter offset and later renamed it to page for consistency with other APIs. Some client code still sends offset. If you ignore unknown parameters, the request "works", but returns the wrong page. You then spend time debugging something that could have been caught immediately.

With strict validation the client would get a clear error about an unknown parameter, and the problem would be visible right away.

My personal view is that even though an API has no visual UI, it still has UX. Clients should receive clear, precise error messages, not a generic "Provided data is incorrect". Detailed validation errors are also in the interest of the backend team: fewer support tickets, less time spent guessing what went wrong on the client side.

The Idea

The kind of validation I needed has actually existed for years, just not in the form of typical Symfony validators. I am talking about the JSON Schema standard: https://json-schema.org/specification

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

So the idea was simple: instead of forcing my 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 was already a solid PHP implementation: https://opis.io/json-schema/

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:

  • Make it convenient to plug this validation into a Symfony project

  • Wire it into the request lifecycle

  • Provide a way to map validated data into DTOs when needed

  • Integrate with Nelmio so that OpenAPI documentation is generated from the same schemas

Below I will show a couple of short examples. In the The Full Story section I describe how I removed duplication between validation attributes and documentation, and how the bundle ended up solving both validation and API specification generation from a single source of truth: the JSON Schema files.

Quick Examples

The schema itself

{
    "$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 behavior.

  • Contract-centric design: the entire API contract lives in clean JSON files rather than being scattered across PHP attributes and PHP classes.

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


The Full Story

This part contains the longer, story-like explanation of how the bundle appeared and evolved in a real project.

How this bundle started

The story of this bundle started when I was building an API gateway for two-way data exchange between internal systems and external consumers. The exact domain does not matter. What matters is that in those conditions nobody really knew which entities we should work with, what would be needed in a week, a month or a year, or what the long-term goals even were. Business kept bringing problems, and API development was just reacting to them. And this, in my opinion, is the first inconvenient truth that people prefer not to talk about. The folks who speak at big conferences with beautiful slides should sometimes sync up with real life, because out there things often look very different from what they present. I worked in that jungle and survived, so I feel I have the right to write about it.

So tasks appeared as a direct response to market pressure, and we simply had to take them and somehow get them done. At first there was no validation at all. It appeared later as a way to close support tickets for various bugs. It was implemented manually in a separate service and looked very straightforward, which in practice meant that changing anything there was far from easy.

Documentation, 500 routes and Nelmio

Over time another problem came up: the API grew to around 500 routes, and we needed some kind of documentation, otherwise we had to explain manually to everyone what to send and where. I tried to maintain a manual specification and gave up after maybe a couple of weeks - it is absolutely miserable work: all those OpenAPI documents you have to write and keep up to date as plain text.

My research into how to automate this led me to the NelmioApiDocBundle, which generated the API specification from annotations in the code. At that time PHP with native attributes had not been released yet. In general this solved the documentation problem for quite a while - I do not even remember how long, probably about a year. But this approach revealed another issue: more and more complaints appeared that the real behavior of the API did not match what the documentation declared for some routes.

And it is easy to see why. Annotations - and even modern attributes - have no connection to the actual runtime data. Code like this will happily generate documentation, but it has no idea what the controller really returns and cannot influence it in any way:

	/**
	 * @OA\Get(
	 * 	path="/units/{setId}",
	 * 	tags={"Unit Data"},
	 * 	summary="Unit by set",
	 * 	description="List of all units, selected setID, that has specified type",
	 * 	operationId="unitSetAction",
	 * 	@OA\Parameter(ref="#/components/parameters/ContentTypeParam"),
	 * 	@OA\Parameter(ref="#/components/parameters/AcceptLanguageParam"),
	 * 	@OA\Parameter(ref="#/components/parameters/AcceptParam"),
	 * 	@OA\Parameter(ref="#/components/parameters/AcceptCharsetParam"),
	 * 	@OA\Parameter(ref="#/components/parameters/xToken"),
	 * 	@OA\Parameter(ref="#/components/parameters/limitParam"),
	 * 	@OA\Parameter(ref="#/components/parameters/skipParam"),
	 * 	@OA\Parameter(ref="#/components/parameters/sortParam"),
	 * 	@OA\Parameter(ref="#/components/parameters/dateToParamDate"),
	 * 	@OA\Parameter(ref="#/components/parameters/dateFromParamDate"),
	 * 	@OA\Parameter(
	 * 		parameter="setId",
	 * 		name="setId",
	 * 		@OA\Schema(
	 * 			type="string",
	 * 			enum = {"all","generic","ext","int"},
	 * 		),
	 * 		description="Type of set to search unit by",
	 * 		in="query",
	 * 		required=true
	 * 	),
	 * 	@OA\Parameter(
	 * 		parameter="withoutURL",
	 * 		name="withoutURL",
	 * 		@OA\Schema(
	 * 			type="bool",
	 * 			default=false,
	 * 		),
	 * 		description="Return ULRs instead of IDs",
	 * 		in="query",
	 * 	),
	 * 	@OA\Response(
	 * 		response=200,
	 * 		description="Successful operation",
	 * 		@OA\JsonContent(
	 * 			oneOf={
	 * 				@OA\Items(ref="#/components/schemas/unitSetActionWithoutURLSuccess"),
	 * 				@OA\Items(ref="#/components/schemas/unitSetActionWithURLSuccess"),
	 * 			}
	 * 		),
	 * 	),
	 * 	@OA\Response(
	 * 		response=300,
	 * 		description="Warning messages",
	 * 		@OA\JsonContent(ref="#/components/schemas/Error302"),
	 * 	),
	 * 	@OA\Response(
	 * 		response=400,
	 * 		description="Errors messages",
	 * 		@OA\JsonContent(
	 * 			oneOf={
	 * 				@OA\Items(ref="#/components/schemas/Error401"),
	 * 				@OA\Items(ref="#/components/schemas/Error402"),
	 * 				@OA\Items(ref="#/components/schemas/Error403"),
	 * 			}
	 * 		),
	 * 	),
	 * )
	 * 
	 * @param int setId
	 *
	 * @return Response
	 *
	 * @GET("/v1/units/{setId}/")
	 */
	public function unitSetAction(int $setId): Response
	{
		$this->validateRequest();

		// Some logic that provides data for response

		return new Response(...);
	}

In theory the controller is supposed to accept and return exactly what is described in those annotations: the request parameter setId, the response schema #/components/schemas/unitSetActionWithoutURLSuccess, and so on. Remembering and keeping all that in sync is entirely your personal problem. And of course I did not remember. As a result, the documentation promised one thing while the real API behaved however it wanted - sometimes as written in the docs, sometimes not. I am sure many of you can recall situations where some service documentation describes things that simply do not exist in the service itself.

This is a huge problem. If you have a great application with either no documentation or documentation that lies (which is effectively the same), nobody will be able to use that application. At all.

Somewhere around this point it became clear that I had to find a way to connect validation and documentation so that both are built and enforced from a single source of truth. I do not think it is an exaggeration to say that around that time several important things happened at once: PHP 8 came out with attribute support and Nelmio started using them, and the OpenAPI standard became fully compatible with JSON Schema. All of this together led me to the idea I later implemented: remove from controller attributes everything related to request parameters (path, query, body, headers) and, during API specification generation, inject that part into the OpenAPI document using JSON Schema attached to the controller. You could say that this is when the core logic that later became the bundle was formed.

Why DTO + Assertions didn't help

In essence, the bundle implements a schema-first approach, while all the solutions I knew at that time were class-first ones. This includes Symfony's own validation mechanism and things built on top of it, like FOSRestBundle. I tried all of that, if only because I really did not want to spend time building my own solution - remember, it was the jungle out there.

I tried to systematize the entities the API worked with, turn them into nice, clean entities, and then build DTOs on top of them. In the end I got a whole Tower of Babel made of DTO hierarchies. At the same time I could not actually change the structure of the incoming JSON for optimization purposes, because the data provider on the other side could not change anything on their side. I was buried in deserializers and denormalizers, and although the code technically worked, I was terrified of what would happen when someone asked to add a couple of fields - or worse, to change the structure.

I eventually realized that in a class-first approach two different layers are mixed together: validation and initialization. That is where many of the problems come from. In my view, validation should only validate and return raw but valid data. On the next layer you can build whatever you like from it - DTOs, entities - but you no longer need to recheck anything, and the code becomes much more compact. That is exactly what I ended up doing.

JSON Schema as a single contract language

At some point it became clear that I did not need yet another way to describe the contract only on the backend. I needed a common language that is equally understandable to everyone involved: backend, frontend, external integrators and the people who write documentation. For me that language became JSON Schema.

A schema in this format describes exactly what we are agreeing on: the JSON structure, field types, required/optional flags, ranges, relationships between values. The same file can be read by the backend that validates the request, by Nelmio that builds OpenAPI based on it, and by the team that builds the frontend or integrations and looks at examples and field descriptions.

The important part is that the schema is not tied to Symfony or to any particular stack. Today it is validated through my bundle in PHP; tomorrow the same file can be plugged into a Node.js service or used in integration tests. Instead of keeping three different descriptions of the contract in sync (code, docs and tests), I just keep a set of JSON Schema files in the repository and let everything else grow around them.

Bundle architecture

From experience I knew that validation had to be strict: everything that is not explicitly allowed should be forbidden. So I built into the logic a base schema that at the root level always contains the properties

  • HEADERS

  • PATH

  • QUERY

  • BODY They must exist in the request and must be present (even if empty). The client schema then defines its own rules on top of this. The bundle always resolves the incoming request into this structure (example from the code):

$data = (object) [
    'body'    => $bodyData,
    'query'   => (object) Types::castTypes($request->query->all()),
    'path'    => (object) Types::castTypes($cleanPath),
    'headers' => (object) Types::castTypes($headers),
];

After that it tries to validate this structure ($schemaPath is the client schema that describes the concrete requirements for the data):

/**
 * @param stdClass $data       Data being validated against the schema
 * @param string   $schemaPath Path to json schema
 */
\(this->validator->validateBySchemaFile(data: \)data, schemaPath: $schemaPath);

The rest is mostly about ergonomics and reusability. To make it easy to plug into different projects I extracted this logic into an independent bundle. To tell the bundle which schema should be used for a particular request I wrote a custom attribute:

// Assume this is controller method
public function validateUser(#[MapRequest('..path/to/user-create-schema.json')] ValidatedRequest $request): JsonResponse
{
    // This is a fully valid request
    \(payload = \)request->getPayload();

    return new JsonResponse('OK');
}

Error handling and UX for API consumers

To avoid manually figuring out how to handle invalid data every time, the #[MapRequest()] attribute accepts an argument \(triggerResponse, which is true by default. In that case the bundle throws its own \Outcomer\ValidationBundle\Exception\ValidationException, which contains a structured array of validation violations in \)validationErrors. The philosophy here is simple: the bundle does not make decisions for your application. It only provides information and tools. To turn this exception into a readable message, your application should subscribe to the event and handle the exception in whatever way makes sense for you.

namespace Outcomer\ValidationBundle\Examples\Subscriber;

use Outcomer\ValidationBundle\Examples\Handler\ValidationExceptionHandler;
use Outcomer\ValidationBundle\Exception\ValidationException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Example event subscriber for handling ValidationException
 *
 * This is an example implementation showing how to catch and handle validation
 * exceptions in your Symfony application. Copy this to your src/Subscriber/
 * directory and adjust the namespace to App\Subscriber.
 *
 * The subscriber catches ValidationException and delegates handling to the
 * ValidationExceptionHandler, which formats the error response as JSON.
 */
#[AsEventListener(event: KernelEvents::EXCEPTION, method: 'handleException', priority: 0)]
class ExceptionListener
{
    public function __construct(private readonly ValidationExceptionHandler $validationBundleHandler)
    {
    }

    /**
     * Delegates exception handling to the appropriate handler.
     */
    public function handleException(ExceptionEvent $event): void
    {
        \(exception = \)event->getThrowable();

        match (true) {
            \(exception instanceof ValidationException  => \)this->validationBundleHandler->handleValidationBundleException(\(exception, \)event),
            default => null,
        };
    }
}

namespace Outcomer\ValidationBundle\Examples\Handler;

use Outcomer\ValidationBundle\Exception\ValidationException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;

/**
 * Example exception handler for ValidationException
 *
 * This is an example implementation showing how to handle validation exceptions
 * in your application. Copy this to your src/Exception/Handler/ directory and
 * adjust the namespace to App\Exception\Handler.
 */
class ValidationExceptionHandler
{
    public function handleValidationBundleException(ValidationException \(exception, ExceptionEvent \)event): void
    {
        \(errors = \)exception->getValidationErrors();

        $response = new JsonResponse(
            data: [
                'message' => $exception->getMessage(),
                'errors'  => $errors,
            ],
            status: $exception->getStatusCode()
        );

        \(event->setResponse(\)response);
    }
}

In this setup, for a schema that requires name as string, email as a valid email and age between 21 and 80, a request like this:

curl --location 'hostname/_examples/validation/user' \
--header 'Content-Type: application/json' \
--data '{
    "name": 5,
    "email": "123_gmail.com",
    "age": 19
}'

will return a clear response to the client, where the path to each invalid property is shown as /body/name, /body/email, /body/age, and each value contains information about what rule was violated, why, and what was actually received in the request. One important detail: the bundle casts numeric types in query and path to numbers after extraction.

{
    "message": "Request data is invalid",
    "errors": {
        "/body/name": [
            {
                "expected": "The data (integer) must match the type: string",
                "received": 5
            }
        ],
        "/body/email": [
            {
                "expected": "The data must match the 'email' format",
                "received": "123_gmail.com"
            }
        ],
        "/body/age": [
            {
                "expected": "Number must be greater than or equal to 21",
                "received": 19
            }
        ]
    }
}

API docs generation from schemas

Once it became clear that JSON Schema would be the single contract language, I still had to solve the second half of the problem: how to get human-readable documentation from the same files. In my case this is an OpenAPI spec that people see via Swagger UI or similar tools.

This is where Nelmio came in handy: it already knew how to build OpenAPI from attributes/annotations on controllers. Instead of describing the request structure a second time in Nelmio attributes, I did the opposite - I left in attributes only what is hard to express in a schema (descriptions, tags, examples, response models), and the actual request parameters (path, query, headers, body) I started injecting during documentation generation from JSON Schema.

Technically it works like this: the controller is still annotated with OpenAPI attributes, and the argument has a MapRequest attribute with a path to the schema. When generating docs, Nelmio calls a describer from the bundle, which reads the JSON Schema at that path and turns it into requestBody and OpenAPI parameters. As a result, both the validator and the documentation rely on the same schema file, not on two separate descriptions that can easily drift apart.

Conclusion

If there is one lesson I took from this project, it is that real-world APIs rarely fit into the neat class-first patterns we are used to. When the contract lives outside of your codebase and keeps changing, you need a single, explicit description of that contract - and everything else should grow around it.

For me, JSON Schema became that description. The bundle I built is just glue that connects Symfony, Opis JSON Schema and OpenAPI generation so that validation, documentation and DTO wiring all follow the same source of truth. It does not try to be a framework on top of a framework; it just removes duplication and keeps behavior and docs in sync.

If your API feels like a jungle of legacy constraints and ad-hoc rules, you might benefit from the same approach. Start by writing down the contract as JSON Schema, plug it into validation, and only then think about DTOs and nice controller signatures. The bundle is my way of making that path a bit easier. And as a pleasant side effect, the DTOs you end up with do not have to stay on the backend. If your frontend uses TypeScript or another typed language, you can mirror these response shapes there and deserialize API responses into the same structures. That way both sides of the system talk about the data in the same terms, and the JSON Schema stays the single contract that keeps them in sync.

This is the first public version of the bundle. If it turns out to be useful in your projects and you see gaps or rough edges, I will be happy to improve it together.

Symfony

Part 1 of 1