TL;DR:
I created a bundle for request validation via JSON Schema because I was fed up with the lack of a simple "schema-first" validator. Now, I just attach a JSON file to a route, and everything - validation, DTO mapping, and OpenAPI documentation - works out of the box.
- Repo: https://github.com/outcomer/symfony-json-schema-validation
- Docs: https://outcomer.github.io/symfony-json-schema-validation/
Feel free to ask me whatever relevant to topic via comments here;
The Pain Point
I didn't set out to build my own bundle. I had a simple task: validate an incoming request against specific conditions.
Sounds basic, right? But when I looked for a solution, I found that the modern Symfony world is stuck in one specific pattern:
- create a DTOs;
- write a ton of assertions (Annotations/Attributes);
- if the structure is complex or dynamic-suffer and write custom normalizers;
I searched everywhere but couldn't find a straightforward way to say: "Here is my JSON, here is my schema, just check them and give me an object." Instead, I was forced to create mountains of boilerplate that I didn't need.
Why DTO + Assertions isn't always the answer
The classic approach with assertions in PHP classes works fine for simple forms or strictly structured data. But as soon as you deal with deep nesting or requirements that don't play well with normalization - it's much easier to define the requirements once in the JSON Schema standard. Otherwise, you end up duplicating logic - firstly in code, then in docs.
I wanted flexibility. I wanted to use a standard that everyone understands - from frontend developers to QA engineers.
Solution
How I solved it: I built a bundle that makes JSON Schema a "first-class citizen" in Symfony. Now, instead of describing validation rules in PHP code, I simply point to a schema.
The bundle allows you to validate data either into a built-in DTO (which always contains validated path, query, headers, and body) or into your own custom class, provided it implements the ValidatedDtoInterface.
Example 1: Validation into the built-in ValidatedRequest
#[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 into your own 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 %1$s::fromPayload() automatically', UserApiDtoRequest::class),
], 201);
}
What's the result?
- I didn't "reinvent the wheel"; I simply filled a gap in the ecosystem;
- No more duplicate work: No more writing endless
#[Assert\NotBlank],#[Assert\Type("string")], etc; - Automatic Sync: Nelmio picks up the schema from the same file - your documentation never lies;
- Contract-Centric: The entire API contract lives in clean JSON files rather than being scattered across PHP attributes;
If you, like me, were looking for a way to validate requests like a human being without creating dozens of redundant classes - here is the solution:
Top comments (0)