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: https://github.com/outcomer/symfony-json-schema-validation
- Docs: https://outcomer.github.io/symfony-json-schema-validation/
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"thenuser.company.nameis required - If
type = "person"thenuser.companymust 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:
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
{
"$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:
- Repo: https://github.com/outcomer/symfony-json-schema-validation
- Docs: https://outcomer.github.io/symfony-json-schema-validation/
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 greenfield 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):
https://outcomer.hashnode.dev/symfony-bundle-that-validates-anything-and-everything#the-full-story
Top comments (0)