hadesarchitect/json-schema-bundle
A Symfony bundle for integrating JSON Schema into your application. It helps validate JSON data against schemas and organize schema-based validation through Symfony services and configuration, making JSON payload checks easier to reuse.
This package is a Symfony bundle, not a Laravel package. Even at v1.0.0 it ships no service provider, no facade, no config publishing, and no Laravel-facing API. Read this assessment as "what the bundle does, and how to get that behaviour in a Laravel app."
What the bundle actually gives you: thin Symfony DI wiring around justinrainbow/json-schema — it registers the validator (and, depending on version, schema/URI storage) as container services so you can inject JsonSchema\Validator instead of new-ing it up. That is essentially the whole bundle. The interesting logic lives in the underlying library.
What v1.0.0 changed (and didn't): dependencies bumped to current stable, tests for long-dead PHP/Symfony versions dropped, service and parameter names switched to a vendor-based scheme, and an MIT license added. No new validation features, no new schema-draft support, no framework-agnostic entry point. The bundle is now legally usable (it previously shipped without a license, which was a genuine compliance blocker), but it is still a Symfony DI shim.
Minimal steps in a Laravel app:
composer require justinrainbow/json-schema
DependencyInjection/HadesArchitectJsonSchemaExtension.php and Resources/config/services.xml to learn the exact service id and any config keys. In v1.0.0 these ids are vendor-prefixed — anything that referenced the old hades_architect.json_schema.* style ids, in YAML, XML, or get('...') calls, needs updating. The service name is still the only real API surface.// app/Providers/JsonSchemaServiceProvider.php
public function register(): void
{
$this->app->bind(\JsonSchema\Validator::class, function () {
$storage = new \JsonSchema\SchemaStorage(
new \JsonSchema\Uri\UriRetriever(),
new \JsonSchema\Uri\UriResolver()
);
return new \JsonSchema\Validator(new \JsonSchema\Constraints\Factory($storage));
});
}
file:// URIs, e.g. resources/schemas/.First use case: validating an inbound webhook/API payload against a contract you commit to the repo. That is the sweet spot — third-party payloads, config files, event envelopes, anything where you want the contract itself version-controlled rather than expressed as scattered $request->validate() rules.
$validator = app(\JsonSchema\Validator::class);
$schema = (object) ['$ref' => 'file://' . resource_path('schemas/webhook.order.created.json')];
$data = json_decode($request->getContent()); // note: NO `true` second arg
$validator->validate($data, $schema);
if (! $validator->isValid()) {
return response()->json(['errors' => $validator->getErrors()], 422);
}
Wrap it in a dedicated service, not in controllers. Controllers should not know about SchemaStorage, UriRetriever, or error-code constants. A single JsonSchemaValidator class with validate(string $schemaName, mixed $payload): ValidationResult keeps the exit path clean if you later swap to opis/json-schema.
Custom validation rule for form requests. If the schema is one of many rules on a request, implement Illuminate\Contracts\Validation\Rule:
public function passes($attribute, $value)
{
$validator = app(\JsonSchema\Validator::class);
$validator->reset(); // defensive, see Gotchas
$validator->validate($value, (object) ['$ref' => 'file://' . $this->schemaPath]);
$this->errors = $validator->getErrors();
return $validator->isValid();
}
This composes with normal Laravel rules (required, array) and keeps the 422 response shape consistent.
Middleware for contract-first endpoints. When the whole body must match a schema before the controller runs, a route middleware that reads $request->getContent(), validates, and short-circuits with 422 is cleaner than sprinkling rules. Resolve the schema name from a route default so it's declarative:
Route::post('/webhooks/orders', OrderWebhookController::class)
->middleware('json.schema:webhook.order.created');
Map errors once, centrally. getErrors() returns entries like ['property' => 'items[0].sku', 'pointer' => '/items/0/sku', 'message' => '...', 'constraint' => 'type']. Convert pointer to dot notation in one place and emit Laravel-style ['field' => [...messages]] so clients can't tell whether a failure came from Validator or Laravel's validator.
Schema composition via $ref and SchemaStorage. Keep small, reusable schemas (address.json, money.json) and reference them by relative URI with an id on the parent schema. SchemaStorage handles resolution and caches within a request — this is the one place the bundle's DI wiring genuinely helps, because you want a single storage instance per request, not one per $ref.
CI gate with an Artisan command. Add php artisan schema:lint that loads every file in resources/schemas, validates each against the draft-04 meta-schema, and resolves all $refs. Run it in CI. Broken $refs are otherwise only discovered on the first bad webhook in production.
Contract tests with fixtures. Keep tests/Fixtures/schemas/* with a valid and an invalid payload per schema, assert isValid() and assert the specific property that failed. These double as living documentation and catch accidental schema loosening in review.
Validate config and event envelopes too. The same service is useful for config/*.json at boot (fail fast on malformed service-account files) and for messages consumed off a queue before you hand them to a job — a cheap guard against producers drifting from the contract.
Pinning, if you inherit the bundle. v1.0.0 is a major bump whose headline change is a rename of the DI surface, so treat the upgrade as a code change, not a composer update. Pin ^1.0 in a legacy Symfony app and grep for the old service ids before deploying; in a Laravel app you shouldn't have the bundle installed at all.
Do not install this bundle in Laravel. It targets Symfony-era conventions (service definitions, extension classes, DI-only integration) and, despite the 1.0.0 tag, still has negligible adoption and a single maintainer. You get no support SLA and no security response, and the DI wiring doesn't map onto Laravel's container. Take the underlying library, or better, a maintained alternative.
v1.0.0 renamed the services and parameters — this is the upgrade hazard. Any YAML/XML service definition, compiler pass, or $container->get() string referencing the old names breaks. The "vendor-based service and parameter names" change is the entire reason this is a major version. Upgrade path: diff Resources/config/services.xml and the extension class between the tag you had and v1.0.0, then grep your app for every old id.
The license is new — good news, but check the dependency tree, not just this bundle. Prior releases shipped without a license file, which made them effectively unusable in a commercial codebase. MIT resolves that for the bundle wrapper; the underlying justinrainbow/json-schema has its own MIT license, so the combination is now clean. Don't let that change the technical verdict in the next bullet.
The underlying library is draft-04 only. No const, no if/then/else, no propertyNames, no draft 2019-09/2020-12 features. exclusiveMinimum/exclusiveMaximum are booleans modifying minimum/maximum, not numbers. If your schemas come from a modern generator they will silently misbehave. For new work, opis/json-schema supports draft-06/07/2019-09/2020-12 and is actively maintained; justinrainbow/json-schema should be treated as legacy-maintenance only. v1.0.0 did not change this — the dependency bump moves the Symfony/PHP test matrix, not the JSON Schema draft.
The json_decode assoc-array trap — this is the one that bites everyone. json_decode($json, true) turns {} into [], so an empty JSON object fails "type": "object" and an empty array passes as an object. Always decode to stdClass (json_decode($json), no second arg) when feeding the validator. If your payload went through Laravel's $request->all(), you already have assoc arrays and you've lost the distinction — validate raw content instead.
Validate on a fresh Validator each time. The validator accumulates state; getErrors() and isValid() reflect the last validate() call only if you reset() between uses. This is exactly why the singleton binding above is a mild footgun — prefer bind(), or make reset() the first line of your wrapper.
Absolute file:// paths only. Relative URIs are resolved against the schema's id, not the CWD, and base_path() vs resource_path() vs storage_path() mismatches produce "unresolvable ref" errors with unhelpful messages. Debug by echoing the fully-resolved URI before calling validate().
Remote $refs are a request-path hazard. UriRetriever will happily make an HTTP call mid-request for an http:// schema URI. Never allow this in a hot path: pin $refs to file://, or subclass UriRetriever to whitelist/deny hosts. Combine with a hard timeout.
Error messages are verbose and not user-facing. message from the library is developer text. Translate to human messages keyed on constraint (type, required, format, pattern, enum) rather than passing library strings through to API clients.
Silent type coercion. validate($value, $schema, $checkCoercion = true) will coerce and pass things you expected to fail. For strict API contracts, pass false explicitly and test a "5" vs 5 case.
Extension points, if you must stay on it: inject a custom JsonSchema\Constraints\Factory (custom UriRetriever, UriResolver, or constraint overrides) through the constructor — that's where the bundle's DI config leaves room. Do not fork the bundle for this; fork the service binding in your own provider. Note that after the v1.0.0 rename, any DI override you wrote must target the new vendor-prefixed id.
Budget for the bus factor. A 1.0.0 tag with a license and modernized dependencies is a real improvement in hygiene, but it is one maintainer making maintenance-only changes to a Symfony-era wrapper. Vendor the schema files, keep the validator behind your own interface, and treat swapping implementations as a one-file change. That is the only durable pattern for a dependency this small and this stale.
How can I help you explore Laravel packages today?