radebatz/openapi-verifier
A Laravel package for verifying your API against an OpenAPI specification, helping keep routes, requests, and responses in sync with your docs and catching contract mismatches early in development or CI.
Install as a dev dependency — this is a test/QA tool, not runtime code:
composer require --dev radebatz/openapi-verifier
You need one thing before it does anything useful: an OpenAPI 3 spec file that actually describes your endpoints. For Laravel, the fastest route is Scramble (php artisan scramble:export --path=openapi.json) or hand-maintained zircote/swagger-php annotations. Commit the spec to the repo — it is the contract the verifier enforces.
The spec may be JSON or YAML. JSON needs nothing extra; YAML requires symfony/yaml, which since 2.2.3 is only suggested. If you point the verifier at a .yaml/.yml file without it, you now get a clear message telling you to install it rather than a cryptic parse failure — so just add it up front if you prefer YAML:
composer require --dev symfony/yaml
Read vendor/radebatz/openapi-verifier/README.md first, then src/ (it's small: a Laravel/Slim test helper, a PSR-15 middleware, and Guzzle/PSR-18 middleware, all thin wrappers around league/openapi-psr7-validator). The wrapper classes are what you'll touch; the league validator is where the actual rules live.
First use case — one feature test proving a response matches the contract:
use OpenAPI\Verifier\Laravel\LaravelVerifier; // verify exact class in vendor/
public function test_users_index_matches_spec(): void
{
$this->seed(UserSeeder::class);
$response = $this->getJson('/api/users');
$response->assertOk();
(new LaravelVerifier(base_path('openapi.json')))
->verify($response->baseResponse, 'GET', '/api/users');
}
If a trait is shipped instead of a class, drop it into a base TestCase so every API test inherits the assertion. Either way, do this on one endpoint before wiring it into CI — you'll immediately discover how far your spec has drifted from reality.
Contract smoke test across the whole API. A data-provider test that hits each route once and asserts the response validates is the highest-value use:
public static function endpoints(): array
{
return [
['GET', '/api/users'],
['GET', '/api/users/1'],
['POST', '/api/users', ['name' => 'Ada', 'email' => '[email protected]']],
];
}
Seed fixtures per case and verify. Run this on every PR — it catches removed fields, changed types, and renamed keys that assertJsonStructure never would.
Verify error responses, not just happy paths. Laravel's 422 validation payload (message + errors) and 404 body must be documented in the spec, or the verifier fails. This is where most first-run failures come from — and it's a genuine design win, because undocumented error shapes are a common source of client breakage. Add a case per documented status code.
Guzzle / PSR-18 middleware for outbound APIs. Useful when your app consumes a third-party API that publishes a spec. Attach the middleware to a handler stack and validate every response the client receives:
use GuzzleHttp\HandlerStack;
use OpenAPI\Verifier\Guzzle\OpenApiVerifierMiddleware;
$stack = HandlerStack::create();
$stack->push(OpenApiVerifierMiddleware::create(base_path('specs/billing.json')));
Http::globalOptions(['handler' => $stack]);
$invoice = Http::get('https://sandbox.billing.test/v1/invoices/42')->json();
This gives you early warning that a vendor broke their contract before your code does. Vendor specs are often published as YAML — install symfony/yaml and you can load them directly rather than converting to JSON first.
PSR-15 middleware in staging, never production. Laravel's middleware pipeline is not PSR-15, so this path requires a bridge and is the least convenient option in Laravel. If you want runtime drift detection, run it in a staging environment only — it parses and compiles the spec per request and will cost you latency.
Wrap it once. Put a tests/Concerns/VerifiesOpenApi.php trait around the package (spec path, default method/path extraction from the current test request, sane validation settings). You then have one place to pin version quirks, swap to league/openapi-psr7-validator directly, or add a Pest expectation via expect()->extend('toMatchOpenApiSpec', ...). Make the spec path a single constant so JSON→YAML (or vice versa) is a one-line change.
Keep the spec in the loop. Regenerate/export the spec in CI before the test job, and fail the build if git diff --exit-code openapi.json (or openapi.yaml) is dirty — otherwise the contract silently rots.
symfony/yaml, and it's only a suggestion. It won't come in transitively. Since 2.2.3 the package detects the missing dependency and tells you to install it — treat that message as the fix, not a parse bug in your spec.$response->baseResponse is a Symfony response. The Laravel helper presumably handles this — if it doesn't, add symfony/psr-http-message-bridge + nyholm/psr7. This is the most common "why doesn't it work" cause.additionalProperties defaults to true. Extra keys in a JSON object pass silently unless you set additionalProperties: false. Don't assume the verifier catches over-posting of response fields.format: date-time / email are annotations, not constraints, in the underlying league validator unless you enable format validation via ValidationSettings. If you need RFC 3339 enforcement, configure it explicitly.1.0 as 1 unless JSON_PRESERVE_ZERO_FRACTION is set; big integers and money-as-strings routinely break type: number vs type: string assumptions. Decimals cast to strings in Eloquent are a frequent offender.204 No Content must have no content block, and response()->json([]) returns {} not an empty body — spec accordingly.Http::fake() bypasses the handler stack, so Guzzle middleware verification and faking are mutually exclusive. Use the verifier in integration tests against a sandbox, fakes in unit tests.setUpBeforeClass) rather than per assertion, or your suite will crawl. This matters more with YAML, where parsing pulls in an extra library.{id} parameter, the verifier wants a matching request path value. Pass the concrete path (/api/users/1), not the template.getJson() / withHeader('Accept', 'application/json') in verifier tests, or you'll be validating a Symfony error page against your JSON schema.require-dev, and isolate it behind your own trait so swapping to league/openapi-psr7-validator directly is a one-file change.league/openapi-psr7-validator, so ValidatorBuilder and ValidationSettings are your escape hatches for strictness tuning, and the package's middleware factories accept those settings where exposed.How can I help you explore Laravel packages today?