macpaw/behat-openapi-psr7-validator
Behat extension for validating PSR-7 HTTP requests and responses against OpenAPI specifications. Add schema-driven API contract checks to Behat scenarios with ready-to-use steps for request and response validation.
Install the extension as a dev dependency alongside Behat:
composer require --dev macpaw/behat-openapi-psr7-validator
This pulls in league/openapi-psr7-validator transitively — that library does the actual validation, so its docs are your best reference once the extension is wired up.
Upgrading note (v1.1.0): this release only widens the Composer constraints (plus an internal PHPStan fix and repo tooling). There is no config change and no public API change, so an existing behat.yml keeps working — it's a drop-in bump. The practical upside is that v1.1.0 is the release to move to if you were previously pinned away from the extension by a newer Behat or newer league/openapi-psr7-validator; the downside is that resolution now varies more between environments (see the pinning gotcha below). composer update macpaw/behat-openapi-psr7-validator -W and check the resulting composer.lock diff before merging.
Register the extension in behat.yml (confirm the exact FQCN from the package README — it's a single Extension class in the MacPaw namespace):
default:
extensions:
MacPaw\BehatOpenApiPsr7Validator\Extension:
openapi_file: '%paths.base%/openapi.yaml'
suites:
default:
contexts:
- FeatureContext
Where to look first:
Extension class — it defines the config keys and where validation hooks into the Behat lifecycle.league/openapi-psr7-validator's Validator::validate() — the extension is a thin Behat adapter around this, so understanding it explains 90% of the error messages you'll see.servers: block — validation matches on the request URI relative to the server base path.First use case: point the extension at a minimal openapi.yaml covering one endpoint, write a scenario that performs a single request through a PSR-7 client, and deliberately break the response (wrong status code, extra field). You should see a validation failure tied to the scenario rather than a normal assertion failure. Once that loop works, expand the spec endpoint by endpoint.
Capture requests/responses with a PSR-7 client. The extension only validates PSR-7 messages, so your context needs to produce them. The common patterns:
Middleware::history($container) to the client and read the last transaction in an @AfterScenario hook.Psr18Client, but note the adapter re-wraps bodies; keep the original stream.Psr\Http\Client\ClientInterface and store the last request/response pair on the context. This is the most controllable option for Behat and avoids history-middleware ordering surprises.Expose the transaction to the extension by whatever mechanism it expects (a context method, a registered service, or a tag). Keep this in one base context so every suite gets it for free.
Validate in an @AfterScenario or @AfterStep hook, not inline. Inline validation mixes HTTP transport assertions with contract assertions and makes failures hard to attribute. After-scenario validation gives you a clean "this scenario violated the spec" signal.
Tag-scope validation.
@openapi
Scenario: List pets
When I GET "/pets"
Then the response status should be 200
Then gate the hook on tags so smoke/health-check scenarios and third-party integrations don't fight the validator. Pair this with path filtering in the spec (or an ignore list) for endpoints like /health, /metrics, and auth callbacks.
Treat the spec as the single source of truth in CI. Run the suite with validation enabled on every PR. Drift between the spec and implementation is exactly what this tool exists to catch — the value comes from it being mandatory, not opt-in.
Validate negative cases too. A 400 response is as much a contract as a 200. Define the error envelope in the spec and assert it; this is where most teams find undocumented fields.
Version specs per branch when the API is in flux. openapi_file: '%paths.base%/specs/%env(API_VERSION)%/openapi.yaml' prevents a feature branch from validating against main's spec.
OpenAPI 3.0.x, not 3.1. league/openapi-psr7-validator has long-standing gaps around 3.1 (type: [string, null], prefixItems, JSON Schema 2020-12 semantics). If your spec is 3.1, either downgrade to 3.0.3 or expect silent non-validation on the new constructs. Widened constraints in v1.1.0 will pull whatever validator minor Composer resolves, so re-check this after upgrading — don't assume the 3.1 gaps closed.
Rewind body streams before validating. PSR-7 streams are single-pass. If your context (or a logger, or Guzzle's history middleware) has already read the response body, validate() sees an empty string and passes trivially — the worst kind of false negative. Call $response->getBody()->rewind() in the hook, and check stream size in a debug run.
Base path and servers: must line up. The validator resolves the operation from the request path against servers[].url. A trailing slash, a /api/v1 prefix applied by the client but missing from the spec, or an absolute URL with a different host will produce confusing "path not found in spec" errors rather than a schema mismatch.
$ref resolution depends on the current working directory. Relative refs inside the spec resolve against the spec file's directory in most setups, but external file refs break if Behat is invoked from a subdirectory. Use absolute %paths.base% and prefer bundled specs or full URLs for external refs.
additionalProperties is the silent killer. By default, extra response fields are allowed unless you set additionalProperties: false. Teams often think the validator is "not working" when in fact the spec is permissive. Inversely, strict mode + a framework that serializes nulls eagerly will flood you with failures — normalize with nullable: true or suppress null fields in your serializer.
Content-Type matching includes parameters. application/json; charset=utf-8 matches application/json in most versions, but application/problem+json does not match application/json. Declare every media type your API actually emits.
Validation failures surface as Behat exceptions, not step failures. Depending on the hook phase, a failure may show as a scenario error ("exception thrown in AfterScenario") rather than a red step. Read the full Behat output in CI — a truncated reporter can hide these.
Debug by isolating from Behat. When a failure makes no sense, reproduce it in a throwaway PHP script:
$validator = (new ValidatorBuilder())->fromYamlFile('openapi.yaml')->getValidator();
$validator->validate($request, $response); // throws on failure
If it throws there too, it's a spec problem. If not, it's a wiring/stream problem in the extension.
Performance. The spec must be parsed once. If you suspect it's being re-read per step, profile — parsing a large spec on every scenario will dominate your suite runtime. The extension should cache the Validator instance; if it doesn't, wrap it in your own singleton.
Pin the combination — more so since v1.1.0. This is a thin wrapper (1 star, 0 dependents), so the league/openapi-psr7-validator minor version effectively defines behavior — and v1.1.0 deliberately widened its Composer ranges rather than narrowing them, so two machines (or CI vs. a colleague's vendor/) can now resolve different validator minors from the same composer.json. Commit composer.lock, run composer install (never update) in CI, and use composer why league/openapi-psr7-validator to confirm which version a failing pipeline actually got. Schema-strictness changes between validator minors have broken suites before.
Extension points worth knowing: swap the PSR-17 factory (some factories differ on multipart/mixed handling), subclass the extension's validator builder to inject custom format validators (e.g., for your ULID or money-string formats), and add a "skip" predicate in your context for endpoints that are intentionally outside the contract — better than polluting the spec.
How can I help you explore Laravel packages today?