joipolloi/json-validation-bundle
A Symfony bundle for validating JSON payloads against schemas, helping you enforce request structure and return clear validation errors in your PHP applications.
The first thing to internalise: this is a Symfony bundle, not a Laravel package. Do not composer require joipolloi/json-validation-bundle in a Laravel app. Installation will succeed but nothing you install will ever run — the bundle's three moving parts (a bundle class registered in bundles.php, a Doctrine annotation read by Symfony's argument resolver, and a kernel event listener that intercepts the request before the controller) have no destination in Laravel's pipeline. You would be adding an unmaintained dependency with zero dependents to your supply chain in exchange for dead code.
What you actually adopt is the pattern. The bundle is a thin wrapper around a JSON Schema engine (almost certainly justinrainbow/json-schema, the de facto PHP implementation of that era) plus Symfony plumbing. Strip the plumbing and what remains is: decode the body, validate against a schema file, map violations into the framework's error collection. That is a FormRequest hook in Laravel, and it is under 100 lines.
Minimal path:
composer require opis/json-schema (draft-07 and 2019-09, actively maintained, faster) or justinrainbow/json-schema (draft-04/06, what the bundle wraps — only choose this if your schemas are already draft-04). Check your schema's $schema keyword before choosing.tinker. This de-risks the only genuinely uncertain part — dialect coverage — without touching application code.resources/schemas/partner/order-created.v1.json. Contract-first: the schema is the source of truth, not the PHP rules.Where to look if you do read the upstream source: the annotation class, the constraint/validator class, and the request listener. Those three files are the entire idea; the rest is Symfony scaffolding.
Pattern A — FormRequest base class (the one you want). Endpoint requests declare a schema and inherit validation, 422 shaping, and content negotiation.
abstract class JsonSchemaRequest extends FormRequest
{
/** @return string schema key resolved by SchemaRepository */
abstract protected function schema(): string;
public function rules(): array
{
return []; // all validation comes from the JSON Schema
}
public function withValidator(Validator $validator): void
{
$validator->after(function ($v) {
$result = app(SchemaRepository::class)
->validate($this->schema(), $this->json()->all());
if ($result->isValid()) {
return;
}
foreach ((new ErrorFormatter())->formatKeyed($result->error()) as $pointer => $messages) {
$v->errors()->add($this->pointerToDot($pointer), $messages[0]);
}
});
}
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(response()->json([
'type' => 'https://example.com/problems/invalid-request',
'title' => 'Invalid request payload',
'status' => 422,
'errors' => $validator->errors()->toArray(),
], 422, ['Content-Type' => 'application/problem+json']));
}
}
final class StoreOrderRequest extends JsonSchemaRequest
{
protected function schema(): string
{
return 'partner/order-created.v1';
}
}
This is deliberately withValidator + after() rather than a Rule object: Laravel's Rule interface can only $fail($message) against the attribute it was attached to. A single JSON Schema produces violations at many different paths, and you want them keyed under those paths so the frontend can map them to form fields. after() lets you add errors under arbitrary keys.
Pattern B — a reusable Rule object when you only care about pass/fail and a single message. Useful for a quick endpoint or for non-HTTP validation (config ingestion, queue payloads).
final class JsonSchemaRule implements ValidationRule
{
public function __construct(private string $schemaKey) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$result = app(SchemaRepository::class)->validate($this->schemaKey, $value);
if (! $result->isValid()) {
$fail('The :attribute does not match the required schema.');
}
}
}
Pattern C — middleware for endpoints that aren't FormRequest-backed or that need to validate before route model binding runs. Keep it thin; it should delegate to the same SchemaRepository.
Schema repository and caching. Schemas are static per endpoint, so parse once per worker and reuse. Bind a singleton:
final class SchemaRepository
{
private array $parsed = [];
public function __construct(private Validator $validator, private string $root) {}
public function validate(string $key, mixed $payload): ValidationResult
{
$schema = $this->parsed[$key] ??= $this->validator->parseSchema(
file_get_contents($this->root.'/'.$key.'.json')
);
return $this->validator->validate(
json_decode(json_encode($payload)), // array -> stdClass for object semantics
$schema
);
}
}
Resolve $ref to local files only, never to remote URLs — a remote $ref is an SSRF vector and a latency dependency in your request path.
Error mapping. Convert JSON Pointer (/shipping/address/postcode) to the dot notation Laravel's error bag and your frontend already understand (shipping.address.postcode). Do this once, in one method, with a unit test. Do not let the raw pointer leak into your public error envelope unless your client contract is explicitly JSON Pointer (RFC 6901) — mixing the two conventions across endpoints is a frontend bug factory.
Shadow mode. Before you enforce on a high-traffic endpoint, ship it as validate-and-log-only behind a config flag. You will discover payload drift (clients sending undeclared but benign fields) that would otherwise become a 422 outage.
Contract tests. For each schema, keep a set of fixtures next to it: one valid payload and several known-invalid ones (missing required field, wrong type, extra property, bad enum). Assert the invalid ones are rejected. This is the only defence against the failure mode below.
Don't shim Symfony onto Laravel. The temptation to reuse the bundle's EventDispatcher and HttpFoundation request adapters is a trap. Request::getContent() vs $request->json()->all() differ in semantics (empty body, malformed JSON, content-type handling), and the shim leaks through the whole call stack. Reimplement.
Silent pass on dialect mismatch is the highest-severity bug. If your schema uses a keyword the engine doesn't implement (const, if/then/else, exclusiveMinimum as a number, unevaluatedProperties), the engine may ignore it and pass payloads that should fail. It's invisible. Verify your engine supports the draft in your $schema and pin it with contract tests.
$request->all() merges the query string into the payload. For GET-with-body or endpoints with query params, this pollutes the document you're validating and produces confusing "additional properties" errors. Use $request->json()->all().
Malformed JSON looks like a schema failure. An empty or unparseable body decodes to [], and the schema then reports "required property id missing" instead of "invalid JSON". Check Content-Type, decode with json_decode($body, true, 512, JSON_THROW_ON_ERROR) in a try/catch, and return a distinct 400 before schema validation runs. Also reject non-application/json content types explicitly rather than letting them fall through to [].
Convert arrays to objects before validation. PHP's json_decode($body, true) turns {} and [] into the same array. json_decode(json_encode($payload)) (or json_decode($body) without true) preserves the object/array distinction, which matters for type: object and additionalProperties.
additionalProperties: false is a breaking change, not a tightening. Adding it to a live schema will 422 every client that sends a benign extra field. Treat schema edits as API changes: version the file and roll out per-endpoint, not globally.
Order validation after authentication. If validation runs before auth, unauthenticated clients can burn CPU on schema validation. In Laravel: middleware priority, or simply keep validation in the FormRequest (which runs after route middleware).
Cap body size before the engine sees it. Schema validation is CPU-bound and synchronous. A 10MB nested JSON body — or a schema with an unbounded maxItems on an array — is a cheap DoS vector. Set post_max_size/Nginx client_max_body_size, and put explicit maxItems, maxProperties, and maxDepth (if supported) in schemas that accept arrays. Recursive schemas can be pathological on deeply nested input.
Debug by logging the schema key and the first failing pointer, plus the offending value's type. Nine times out of ten a "surprising" failure is a client sending "42" where the schema says integer, or a null where it says required.
Extension points worth knowing: Validator::extend for a global shorthand, FormRequest::failedValidation for the error envelope, FormRequest::prepareForValidation for normalisation before validation (e.g. trimming string fields), the exception handler's render for a cross-cutting 422 shape, and a container binding for SchemaRepository. Do not subclass the Symfony bundle.
Own the code. The upstream package has 4 stars, zero dependents, and no maintenance signal. Even if you were on Symfony, vendoring would be safer than depending. On Laravel the calculus is trivial: your JsonSchemaRequest is ~80 lines you fully understand, updates only when you bump the engine or change draft, and removes a supply-chain review item entirely. Record the decision (adopt the pattern, reject the dependency) in an ADR so it isn't re-proposed.
How can I help you explore Laravel packages today?