twentytwo-labs/api-validator
Twentytwo Labs API Validator is a Laravel/PHP package for validating API requests and responses with reusable rules. It helps keep endpoints consistent, catches malformed payloads early, and makes validation errors easier to surface in your applications.
This is a small, single-purpose library: it takes a PSR-7 request plus an OpenAPI 2 (Swagger) or OpenAPI 3 document and tells you whether the request matches the contract. It is not a Laravel package — there is no service provider, no config file, no facade. Expect to write ~40 lines of glue yourself.
Install
composer require twentytwo-labs/api-validator nyholm/psr7
You almost certainly need a PSR-7/PSR-17 implementation as well; nyholm/psr7 is the lightest one that plays nicely with Laravel (nyholm/psr7-server if you need to build a request from globals). Under the hood this package delegates to league/openapi-psr7-validator (which in turn uses cebe/php-openapi and opis/json-schema), so most of the behaviour you'll fight with day-to-day comes from those two, not from the wrapper.
Where to look first
README.md — usually one usage snippet, that's the whole documented API.src/ — find the single public entrypoint (typically a Validator/ApiValidator-style class plus a factory that takes a spec path). Grep for function validate and Exception.tests/ — the most reliable documentation. The functional tests will show you the exact constructor signature and the shape of the violation objects, which the README tends to omit.composer.json — check the pinned range of league/openapi-psr7-validator; that tells you which OpenAPI features you actually get.First use case
Validate an incoming POST JSON body against the spec before it hits your controller, so malformed payloads never reach your FormRequest/action:
// app/Http/Middleware/ValidateAgainstOpenApi.php
public function handle($request, Closure $next)
{
if (! app()->environment('local', 'staging')) {
return $next($request);
}
$psrRequest = (new Psr17Factory())
->createRequest($request->method(), $request->fullUrl())
->withBody((new Psr17Factory())->createStream($request->getContent()));
// exact class/method names vary by release — check src/
$result = app(ApiValidator::class)->validate($psrRequest);
if (! $result->isValid()) {
throw new OpenApiViolationException($result->getErrors()); // renders as 400
}
return $next($request);
}
Register it globally or on an api route group only. Start with a single spec and a single route group; do not point it at every route on day one.
Bind the validator as a singleton, compile once. Parsing YAML and resolving $refs on every request is the number-one performance mistake with this class of library. Register a singleton in AppServiceProvider that loads the spec once; the object is stateless and safe to reuse across requests (Octane-safe as long as you don't mutate it).
Validate in two directions, and care more about the response direction. Validating requests catches typos; validating responses in feature tests catches contract drift — the thing that actually breaks consumers. A cheap pattern:
// tests/Concerns/AssertsOpenApi.php
protected function assertMatchesSpec(TestResponse $response, string $specPath): void
{
$psrResponse = /* convert $response to PSR-7 */;
$result = $this->app->make(ApiValidator::class)->validateResponse($psrResponse);
$this->assertTrue($result->isValid(), implode("\n", $result->getErrors()));
}
Call it from a base TestCase for every JSON assertion. This is where the package earns its keep in a Laravel codebase, because Laravel's own validation never checks that your response shape still matches what you promised in the spec.
Map violations to Laravel's error envelope. Don't leak raw library messages ("Value is not valid for schema at #/properties/email") to clients. Walk the violation list, convert JSON-pointer paths (/items/0/sku) into dot notation (items.0.sku), and re-emit as {"errors": {"items.0.sku": ["..."]}} so frontends can reuse the same error-rendering code they use for ValidationException.
One spec per route group / API version. If you serve /v1 and /v2, resolve the spec in the middleware from the route prefix rather than cramming everything into one document. A tiny SpecResolver (read the prefix → return the right path) is the natural extension point and keeps the middleware dumb.
Use it as a test-time guard, not just middleware. A --fail-on-spec-violation pre-deploy step (run the contract tests, fail the pipeline) is worth more than runtime validation, which costs latency and can only ever reject traffic you'd rather have seen in staging.
Fail-open by default in production. Gate the middleware behind an env flag. Runtime contract validation is a development and canary tool; in prod a spec bug becomes a 100% outage. Log-and-continue on isValid() === false in prod, throw in local/staging.
servers/basePath. If Laravel strips a prefix via RouteServiceProvider or you're behind a proxy, the path it sees won't match the spec and you'll get "no route matched" rather than a validation error. Verify the matched operation before you trust any body validation result.?page=2 will not satisfy type: integer under a strict schema. Either add a coercion/loose-type option if the version exposes one, or type query params as strings with a pattern. This bites everyone once.$request->getContent() and hands the stream to the validator, make sure the stream is rewound (or that getContent()'s cached string is used) before the controller runs, or downstream code sees an empty body. Prefer passing the cached string over re-reading php://input.$ref resolution is filesystem-relative. External file refs resolve relative to the spec document's directory, and remote (http://) refs will happily make an HTTP call per parse. Vendor your spec, keep it on disk, and disable remote resolution.additionalProperties: false is where specs hurt most. Auto-generated specs frequently set it and then reject all the fields your existing clients send. Audit the spec before enabling enforcement, or you'll be rolling back on day two.oneOf / discriminator produce useless error messages. A single missing field shows as "value does not match any of the schemas". Budget time for a translator layer that reports which branches failed.bearer in Authorization passes; whether the token is valid is your job. Don't let this replace actual auth middleware.nullable, OpenAPI 3.1 (type: [string, "null"]), and exclusiveMinimum differ between OpenAPI versions and between spec parsers. Confirm which OpenAPI version the underlying parser supports before you convert your spec — 3.1 support has historically lagged.mtime; re-parsing on every deploy is fine, on every request is not.composer.json, and be prepared to drop down to league/openapi-psr7-validator directly — the wrapper's value-add is thin enough that you lose very little by doing so, and you gain a package with actual users.How can I help you explore Laravel packages today?