OpenAPI 3.1 / JSON Schema 2020-12 request and response validation for Laravel
kojirock5260/laravel-json-schema-validate is a Laravel package for openapi 3.1 / json schema 2020-12 request and response validation for laravel.
It currently has 5 GitHub stars and 1.064 downloads on Packagist (latest version v4.0.0).
Install it with composer require kojirock5260/laravel-json-schema-validate.
Discover more Laravel packages by kojirock5260
or browse all Laravel packages to compare alternatives.
Last updated
Validates Laravel requests and responses against an OpenAPI 3.1 document. Schema Objects are passed to opis/json-schema unchanged and validated as JSON Schema 2020-12.
English | 日本語
v4 was rewritten from v3 with Claude Code.
| | | |---|---| | PHP | 8.3+ | | Laravel | 12.0+ | | OpenAPI | 3.1 |
composer require kojirock5260/laravel-json-schema-validate
php artisan vendor:publish --provider="Kojirock5260\JsonSchemaValidate\JsonSchemaServiceProvider" --tag=config
Publishing is optional. The defaults are merged automatically.
config/json-schema.php
return [
// Path to the OpenAPI document. .json / .yaml / .yml are supported.
'path' => env('OPENAPI_PATH', base_path('openapi.yaml')),
// Prefix to strip from route URIs when the spec does not include it in `paths`.
'base_path' => env('OPENAPI_BASE_PATH', ''),
// Where the parsed document is cached. Set to null to disable caching.
'cache' => env('OPENAPI_CACHE', base_path('bootstrap/cache/openapi.cache')),
// Whether to record the shape of the traffic that passes through the middleware.
'observe' => env('OPENAPI_OBSERVE', false),
// Where the recorded shapes are written.
'observations' => env('OPENAPI_OBSERVATIONS', base_path('bootstrap/cache/openapi-observations.jsonl')),
];
Use base_path when routes live under /api but the document describes them as /members:
'base_path' => 'api',
Register the middleware:
// bootstrap/app.php
use Kojirock5260\JsonSchemaValidate\Middleware\ValidateOpenApi;
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['openapi' => ValidateOpenApi::class]);
})
Apply it to routes:
Route::middleware('openapi')->group(function () {
Route::get('/members', [MemberController::class, 'index']);
Route::get('/members/{member}', [MemberController::class, 'show']);
Route::post('/members', [MemberController::class, 'store']);
});
The document is parsed on first use in each process. openapi:cache writes the parsed document to a
file so that later processes restore it instead of parsing it again.
php artisan openapi:cache
php artisan openapi:clear
The cache is ignored when the modification time of the document differs from the one recorded when the cache was written, so editing the document during development does not require clearing it.
Restoring uses more peak memory than parsing, because the whole object graph is materialised at once. Measured with a 303 KB YAML document describing 200 paths:
| | Time | Peak memory | |---|---|---| | Without cache | 54.0 ms | 7.6 MB | | With cache | 7.0 ms | 13.4 MB |
With observe enabled, the middleware records the shape of the traffic that passes through it: query
parameters, and JSON request and response bodies. Two commands consume those recordings.
OPENAPI_OBSERVE=true php artisan test
openapi:generateWrites an OpenAPI document describing what was observed. Routes the current document does not describe are recorded too, so this works with no document at all.
php artisan openapi:generate --output=openapi.yaml
openapi: 3.1.0
info:
title: 'Generated from observed traffic'
version: 1.0.0
paths:
/widgets/{widget}:
get:
parameters:
- { name: widget, in: path, required: true, schema: { type: string } }
- { name: page, in: query, required: false, schema: { type: integer } }
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [id, label]
properties:
id: { type: integer }
label: { type: string }
| Option | Effect |
|---|---|
| --output= | Write to this file instead of standard output |
| --format= | yaml (default) or json |
| --title= | Value for info.title |
| --api-version= | Value for info.version |
openapi:diffCompares the recordings with the current document and lists the differences.
php artisan openapi:diff
GET /members
response 200
body.data[].joinedAt Observed but not described in the specification.
body.data[].name Observed type integer but the specification allows string.
body.data[] The specification requires name but they were not always present.
WARN Found 3 difference(s) between the recorded traffic and the specification.
Reported:
required that was absent from some recordingsFields the document describes but that were never observed are not reported, since that only means the code path was not exercised.
| Option | Effect |
|---|---|
| --strict | Exit with a failure code when differences are found |
| --forget | Remove the recordings after reporting |
Types are unioned and required is intersected. A key absent from any single recording is dropped from
required; a field seen with more than one type becomes a union. The result does not depend on the
order in which the traffic was observed.
This describes what was observed, not what is true. required: false is backed by evidence — a request
succeeded without that field. required: true only means no counter-example was seen. Paths that were
never exercised do not appear at all. A generated document is a starting point to review, not a
finished specification.
Only types and structure are recorded. No values are written to the observations file. Request bodies
are recorded for methods that carry one; GET, HEAD, OPTIONS and TRACE are skipped.
Request
required parametersContent-TypeResponse
4XX), then defaultrequiredContent-Type.json, .yaml and .ymlconst, prefixItems, dependentRequired,
unevaluatedProperties, numeric exclusiveMinimum and type unions such as [string, 'null']$ref within the documentpaths by position, so
members/{member} matches /members/{memberId}, including optional parameters ({member?})?page=3 becomes 3 for
type: integer. A value that cannot be converted is left as-is and fails validationapplication/*, */*) when selecting a content entrynullable: true is ignored, so a field written that way rejects null. Use type: [string, 'null'].
Boolean exclusiveMinimum is not interpreted as 3.0 defines itapplication/json and
+json subtypes have their contents validatedcontent instead of schemaRequest failures throw RequestValidationException, which extends Laravel's ValidationException.
Laravel renders it as a 422 without further configuration:
{
"message": "Number must be greater than 0 (and 1 more error)",
"errors": {
"page": ["Number must be greater than 0"],
"status": ["The data must match the const value"]
}
}
Keys use dot notation. Errors on the body as a whole are reported under body.
Response failures throw ResponseValidationException, a plain RuntimeException, which results in a
500 rather than a 422.
use Kojirock5260\JsonSchemaValidate\Exception\ResponseValidationException;
try {
// ...
} catch (ResponseValidationException $e) {
$e->operation; // "GET /members"
$e->errors; // ['X-Total-Count' => ['The X-Total-Count response header is required.']]
}
Route::middleware('openapi:skip-response')->get('/members', $handler);
Route::middleware('openapi:skip-request')->get('/members', $handler);
Route::middleware(app()->isProduction() ? 'openapi:skip-response' : 'openapi')->group(...);
v4 shares no API with v3.
| | v3 | v4 |
|---|---|---|
| Schema source | PHP classes under App\Http\Schema | OpenAPI document |
| Resolution | Route name equals class name | Path and method |
| Validator | justinrainbow/json-schema | opis/json-schema |
| Dialect | draft-04 era | JSON Schema 2020-12 |
| Error handling | Manual prepareException wiring | Automatic 422 |
SchemaInterface, JsonSchemaValidator and JsonSchemaException have been removed. Rewrite the
schema classes as an OpenAPI document and register ValidateOpenApi.
composer install
composer check # pint --test, phpstan, pest
The MIT License (MIT). Please see License File for more information.