mrsuh/json-validation-bundle
A Symfony bundle for validating JSON documents against JSON Schema. Add schema-based validation to request payloads, forms, and APIs with a dedicated constraint and clear, actionable error messages.
mrsuh/json-validation-bundle is a Symfony bundle (not a Laravel package) that hooks kernel.request / kernel.response and validates the JSON body against a JSON Schema file stored in a configurable directory. There is no service provider, no config/json-validation.php, and no Illuminate integration — so the first day-to-day decision for a Laravel developer is whether you are actually working in a Symfony app. If yes, install it. If no, treat it as a ~150-line reference implementation and port the middleware pattern below; the underlying validation library is the part you want, not the bundle.
Symfony path (where the bundle actually runs):
composer require mrsuh/json-validation-bundle — check its composer.json for Symfony/PHP constraints first; the last tagged release is from March 2023.JsonValidationBundle\JsonValidationBundle in config/bundles.php.# config/packages/json_validation.yaml
json_validation:
schema_dir: '%kernel.project_dir%/config/schemas'
orders/create.request.json into that directory./**
* @JsonSchemaRequestValidate(schema="orders/create.request.json")
* @JsonSchemaResponseValidate(schema="orders/create.response.json")
*/
#[Route('/api/orders', methods: ['POST'])]
public function create(Request $request): JsonResponse { /* body is already valid here */ }
Where to look first (the codebase is small — read it in one sitting): DependencyInjection/Configuration.php for the real config keys and defaults, the Listener/ or EventListener/ classes for the request/response flow and status codes, the Annotation/ classes for attribute vs annotation support, and the validator service to see which engine it wraps (this determines which JSON Schema drafts you can use). Confirm the exact annotation names and schema_dir key against Configuration.php before quoting them in team docs — that file is the source of truth.
First real use case: a POST /api/orders endpoint where the controller can assume a shape-valid payload — no isset() soup, no partial validation in the service layer, and a deterministic 400 for malformed clients.
Laravel path (equivalent, ~40 lines): install opis/json-schema (supports draft-07 through 2020-12) and write one middleware. Register it in a service provider and attach per-route:
Route::post('/orders', StoreOrder::class)
->middleware('json.schema:orders/create.request.json,orders/create.response.json');
Request-only, request+response, or response-only. Start request-only. Response validation is a contract-testing tool, not a production guard — see Gotchas.
Route defaults instead of attributes. For invokable controllers, third-party controllers, or when attribute support is missing on your PHP/Symfony combination, set the schema on the route defaults (_json_schema_request_validate / _json_schema_response_validate style keys — verify names in the listener). This also lets you toggle schemas per environment from routing config.
Schema layout that scales. Mirror your API structure and name by direction:
config/schemas/
common/address.json
orders/create.request.json
orders/create.response.json
orders/show.response.json
Use $id plus $ref for shared definitions (common/address.json#/definitions/address) rather than copy-pasting. One schema per endpoint+direction beats one giant schema per resource; diff noise stays local.
Normalize errors into one shape. The bundle's default error payload won't match your existing API error envelope. Add a kernel.exception listener (or in Laravel, render the exception in Handler::render) that maps JSON Schema error pointers (/items/0/price) into your field-keyed format. This is the single highest-value customization: frontends consume it, and it decouples you from the bundle's error format.
Generate schemas from OpenAPI, not the reverse. If you already maintain an OpenAPI spec, extract components.schemas into the schema directory with a small command rather than hand-writing JSON Schema. Hand-maintained schemas drift within a sprint.
Laravel middleware shape (the port):
final class ValidateJsonSchema
{
public function __construct(
private Validator $validator,
private ErrorFormatter $formatter,
) {}
public function handle(Request $request, Closure $next, ?string $requestSchema = null, ?string $responseSchema = null): Response
{
if ($requestSchema !== null && $request->isJson()) {
$payload = json_decode($request->getContent()); // decode to stdClass: preserves {} vs []
$result = $this->validator->validate($payload, $this->load($requestSchema));
if ($result->hasError()) {
throw new JsonSchemaViolation($this->formatter->format($result->error()));
}
}
$response = $next($request);
if ($responseSchema !== null && app()->environment(['local', 'testing'])) {
$result = $this->validator->validate(json_decode($response->getContent()), $this->load($responseSchema));
if ($result->hasError()) {
Log::warning('json-schema response violation', ['schema' => $responseSchema, 'errors' => $this->formatter->format($result->error())]);
}
}
return $response;
}
}
Throw something that renders as 422 (or 400 if you're matching the bundle) and implement render() so it never falls through to Laravel's HTML error page.
Contract tests in CI. Put a handful of valid/invalid fixtures next to each schema and assert the status code and the error body. This is where the package earns its keep even if you never run it in production — it turns "the API changed" into a red build.
Decorating for observability. In Symfony, decorate the bundle's validator service to count violations per schema and log them (without bodies). In Laravel, do the same in the middleware. A spike in orders/create.request.json violations is usually a client rollout, not a bug.
HttpKernel listeners, doctrine/annotations. Don't add it to composer.json "just to try" — it will pull Symfony contracts you don't want. Port the pattern.{} vs [] is a real trap. json_decode($body, true) turns both into [], so "type": "object" and "type": "array" become indistinguishable and additionalProperties: false checks silently misbehave. Decode to stdClass (or validate the raw string with the engine's validateJson).Content-Type: application/json pass for the wrong reason — use $this->postJson() in Laravel / json() in Symfony's test client.PATCH endpoints.400 from the bundle, while Laravel conventions and most API consumers expect 422 for validation failures. Pick one and normalize — don't let clients see both.nullable, example, and discriminator are not standard keywords. Feeding an OpenAPI-derived schema to a strict validator produces false failures. Strip/normalize keywords during extraction, or pin the validator to the matching draft.additionalProperties defaults to true. A schema without it is not strict. Newcomers assume unknown fields are rejected; they aren't. Same for format — many engines treat it as annotation-only unless you enable assertion.local/testing and log instead of throwing.$ref, resolve paths relative to a fixed root and reject .. in schema names supplied from route parameters.#[MapRequestPayload] plus serializer/constraints covers the common case; this bundle's niche is a shared, language-agnostic JSON Schema contract (e.g. a schema also consumed by a TS frontend or a Go service). If you don't have that constraint, plain validation is simpler.How can I help you explore Laravel packages today?