league/openapi-psr7-validator
Validate PSR-7 HTTP requests and responses against OpenAPI 3.0.x specifications written in YAML or JSON. Matches requests to operations and checks bodies, headers, and metadata.
Install the package alongside a PSR-7 bridge, because Laravel speaks Symfony\Component\HttpFoundation, not PSR-7:
composer require league/openapi-psr7-validator nyholm/psr7 symfony/psr-http-message-bridge
Keep your spec at a predictable path (openapi.yaml / openapi.json at the repo root or resources/openapi/), and treat it as a first-class artefact: the same file your frontend generates types from is the file your tests validate against.
Where to click first: the ValidatorBuilder API. Every entry point is a fluent chain off it — fromYamlFile() / fromJsonFile() / fromYaml() / fromSchema(), then one of getServerRequestValidator(), getRequestValidator(), getResponseValidator(), getRoutedRequestValidator().
The first use case on a Laravel project is not production middleware — it is response contract tests. Turn a feature test into a spec assertion:
use League\OpenAPIValidation\PSR7\OperationAddress;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
$psr17 = new Psr17Factory();
$factory = new PsrHttpFactory($psr17, $psr17, $psr17, $psr17);
$response = $this->getJson('/api/users/42');
$psrResponse = $factory->createResponse($response->baseResponse);
$validator = (new ValidatorBuilder)
->fromYamlFile(base_path('openapi.yaml'))
->getResponseValidator();
$validator->validate(new OperationAddress('/users/{id}', 'get'), $psrResponse);
That single test catches most drift: renamed fields, missing required, wrong status codes, wrong content types. Start there, then widen coverage.
1. One validator, built once, cached. Spec parsing is the expensive part; validation is cheap. In a service provider register a singleton and wire in a PSR-6 pool:
$this->app->singleton(ResponseValidator::class, fn () => (new ValidatorBuilder)
->fromYamlFile(config('openapi.spec'))
->setCache($this->app->make(CacheItemPoolInterface::class), 3600)
->getResponseValidator()
);
Resolve the validator from the container in tests and middleware alike, so the cache is shared.
2. Route-aware request validation. Don’t use getServerRequestValidator() in Laravel — you already know the operation. Use getRoutedRequestValidator() and build the OperationAddress from the current route, which avoids the expensive path-matching pass and gives far better error messages:
$address = new OperationAddress(
'/'.ltrim($request->route()->uri(), '/'), // 'users/{id}' -> '/users/{id}'
strtolower($request->route()->methods()[0])
);
$validator->validate($address, $psrRequest);
This only works if your spec uses {id} braces and your Laravel route uses the same parameter names — align them deliberately.
3. Custom middleware for requests (Laravel is not PSR-15). The bundled middleware only plugs into PSR-15 stacks (and Slim via SlimAdapter). In Laravel, write a normal middleware that converts the request, validates, and maps exceptions to problem-details JSON:
try {
$this->validator->validate($address, $factory->createRequest($request));
} catch (ValidationFailed $e) {
throw new HttpResponseException(response()->json([
'title' => 'Request does not match the API contract',
'detail' => $e->getVerboseMessage(),
'errors' => $e->getPrevious()?->getMessage(),
], 422));
}
Gate this behind a config flag — validate in testing and staging, disable (or log-only) in production unless the cache is warm.
4. Response validation in tests via a trait. Wrap the snippet from Getting Started in an assertion helper ($this->assertMatchesOpenApi($response, '/users/{id}', 'get')) and sprinkle it across feature tests. Add a CI job that hits every documented path with a fixture and validates both success and error responses — the error responses are where specs rot fastest.
5. Standalone schema validation for non-HTTP data. Schema\SchemaValidator is useful well beyond HTTP: validate webhook payloads before persist, queue job DTOs, or config blobs against a schema you extract from the spec:
$spec->resolveReferences(new ReferenceContext($spec, '/'));
$schema = new \cebe\openapi\spec\Schema($spec->components->schemas->Order);
(new \League\OpenAPIValidation\Schema\SchemaValidator)->validate($payload, $schema);
Note the standalone path requires manual $ref resolution; the PSR-7 builders do it for you.
6. Custom formats. Register domain-specific formats once (a service provider boot() is fine):
FormatsContainer::registerFormat('string', 'phone', fn ($v) => (bool) preg_match('/^\+[1-9]\d{6,14}$/', $v));
Then use format: phone in your spec and get validation for free across every endpoint.
7. Security scheme validation as a contract test. The InvalidSecurity family verifies that requests match the declared security scheme (API-key header present, bearer token shaped correctly). It is not authentication — don’t confuse the two. But as a test assertion it catches “we changed from Authorization: Bearer to Authorization: Token” silently.
8. Reuse the compiled schema. $validator->getSchema() returns the cebe\openapi\spec\OpenApi tree. Useful for generating route lists, feeding a docs UI, or asserting that every Laravel route has a matching spec path in a CI test.
type: [string, 'null'] and webhooks in 3.1 simply won’t parse correctly./users/{id} in the spec, matched against /users/42 on the wire. If you’re passing a spec path into OperationAddress, do not pass the concrete URL.OperationAddress is mandatory. This means every response assertion in tests needs the route + method spelled out — build a helper so it stays honest.Content-Type is required for bodies. Missing header → NoContentType, thrown before body validation. In Laravel, $this->postJson() sets it; raw $this->post() does not.$psrRequest->getBody()->rewind() before validating if anything upstream touched the body.204 No Content must be declared. Undeclared status codes raise NoResponseCode, and an empty body with a schema defined is an InvalidBody. Model 204 explicitly with no content.nullable: true needs type alongside it in 3.0. nullable: true on a bare $ref is ignored. Wrap in allOf and add nullable at the sibling level.getPrevious() for the root cause. InvalidBody / InvalidQueryArgs are wrappers. The precise keyword failure lives in the previous exception chain (KeywordMismatch::keyword(), dataBreadCrumb() for nested paths). Log getVerboseMessage() — the plain message is deliberately terse.MultipleOperationsMismatchForRequest means several operations in the spec overlap and none validated. Usually caused by loose path patterns like /users/{id} and /users/{slug} — resolve by matching more specifically in your routing, or switch to getRoutedRequestValidator().form, explode, deepObject) are honoured, but Laravel’s $request->query() flattens things. If you validate the PSR-7 request, the raw query string matters — bracket syntax ?tags[]=a&tags[]=b is read as tags[a]-ish by some parsers. Prefer tags=a&tags=b with explode: true.$refs. External $ref files resolve relative to the spec file’s location. If you cache across deploys, the cache key must change when any referenced file changes — otherwise use overrideCacheKey('openapi-'.md5_file($mainSpec)) and delete the stale entries on deploy.FormatsContainer::registerFormat() state persists across requests. Register in a service provider’s register() (not inside a request handler) so it registers exactly once.swagger-cli validate or cebe/php-openapi parsing in CI; a malformed spec produces confusing runtime errors.\League\OpenAPIValidation\PSR7\ValidationMiddlewareBuilder should be ...\PSR15\...). Copy-pasting will fail to autoload.default response in the spec or skip validation for 5xx in your assertion helper.additionalProperties defaults to true, so extra payload fields pass silently. Set additionalProperties: false on request schemas if you want strictness — but do it deliberately, it breaks forward compatibility with clients.How can I help you explore Laravel packages today?