studio-design / openapi-contract-testing
Framework-agnostic OpenAPI 3.0/3.1 contract testing for PHPUnit with endpoint coverage tracking
Package info
github.com/studio-design/openapi-contract-testing
pkg:composer/studio-design/openapi-contract-testing
Requires
- php: ^8.2
- opis/json-schema: ^2.6
- phpunit/phpunit: ^11.0 || ^12.0 || ^13.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.0 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- guzzlehttp/psr7: ^2.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client implementation for resolving external HTTP(S) $refs
- illuminate/testing: Required for the Laravel adapter (ValidatesOpenApiSchema trait)
- symfony/http-client: A PSR-18 HTTP client implementation for resolving external HTTP(S) $refs
- symfony/yaml: Required to load OpenAPI specs directly from .yaml / .yml files
- dev-main
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.2
- v0.5.1
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.0
- dev-feature/v0.13.0-spec-compliance
- dev-feature/issue-111-coverage-granularity
- dev-feature/issue-108-http-refs
- dev-feature/issue-110-response-header-validation
- dev-feature/issue-108-local-external-refs
- dev-feature/issue-112-readme-comparison
- dev-feature/issue-98-document-vacuous-error-policy
- dev-feature/issue-97-non-empty-array-failure
- dev-feature/issue-92-validator-error-boundary
- dev-feature/issue-82-yaml-refs-e2e-test
- dev-feature/issue-81-yaml-availability-seam
- dev-feature/issue-79-hard-fail-broken-spec
- dev-feature/issue-78-ref-as-property-name
- dev-feature/issue-75-decouple-schema-less-tests
- dev-feature/issue-74-skipped-only-coverage
- dev-feature/issue-73-reject-empty-failure
- dev-feature/issue-72-validation-outcome-enum
- dev-feature/issue-69-wire-request-validator
- dev-feature/issue-68-split-response-validator
- dev-feature/issue-68-split-request-validator
- dev-feature/issue-68-extract-validation-helpers
- dev-feature/issue-67-header-scalar-guard
- dev-feature/issue-66-reserved-header-spec-error
- dev-feature/issue-63-ref-resolver-regression-tests
- dev-feature/issue-62-scalar-content-schema-guard
- dev-feature/issue-52-readonly-writeonly-context
- dev-feature/issue-51-spec-resolver-priority-tests
- dev-renovate/major-symfony
- dev-feature/yaml-spec-loading
- dev-feature/resolve-internal-refs
- dev-feature/skip-response-code-fluent-api
- dev-feature/skip-5xx-responses
- dev-feature/security-scheme-validation
- dev-feature/header-parameter-validation
- dev-feature/path-parameter-validation
- dev-feature/request-body-ref-hard-error
- dev-feature/query-parameter-validation
- dev-feature/request-validator-skeleton
- dev-feature/without-validation-per-request
- dev-feature/skip-openapi-attribute
- dev-feature/auto-assert-test-response
- dev-renovate/major-illuminate
- dev-renovate/configure
- dev-feat/ci-markdown-coverage-output
- dev-feat/support-php84-phpunit13
- dev-fix/ci-matrix-php82-phpunit12
This package is auto-updated.
Last update: 2026-04-25 11:16:44 UTC
README
Framework-agnostic OpenAPI 3.0/3.1 contract testing for PHPUnit with endpoint coverage tracking.
Validate your API responses against your OpenAPI specification during testing, and get a coverage report showing which endpoints have been tested.
Features
- OpenAPI 3.0 & 3.1 support — Automatic version detection from the
openapifield - Response validation — Validates response bodies against JSON Schema (Draft 07 via opis/json-schema). Supports
application/jsonand any+jsoncontent type (e.g.,application/problem+json) - Content negotiation — Accepts the actual response
Content-Typeto handle mixed-content specs. Non-JSON responses (e.g.,text/html,application/xml) are verified for spec presence without body validation; JSON-compatible responses are fully schema-validated - Skip-by-status-code — Configurable regex list of status codes whose bodies are not validated (default: every
5xx), reflecting the common convention of not documenting production error responses in the spec. Also available per-request via the fluentskipResponseCode()API - Endpoint coverage tracking — Unique PHPUnit extension that reports which spec endpoints are covered by tests
- Path matching — Handles parameterized paths (
/pets/{petId}) with configurable prefix stripping - Laravel adapter — Optional trait for seamless integration with Laravel's
TestResponse - Zero runtime overhead — Only used in test suites
Why this library?
This library fills a gap left by existing PHP OpenAPI testing tools: endpoint coverage tracking and first-class OpenAPI 3.1 support, combined with Laravel auto-assert DX. If you already use Spectator and don't need coverage reports, this library won't offer much. If you want to see which endpoints your test suite actually exercises, or you're writing OpenAPI 3.1 specs, this is likely the best choice today.
Feature comparison (as of 2026-04)
| This library | Spectator | league/psr7 | osteel | kirschbaum | |
|---|---|---|---|---|---|
| OpenAPI 3.0 | ✅ | ✅ | ✅ | ✅ | ✅ |
| OpenAPI 3.1 | ✅ | ⚠️ | ❌ | ⚠️ | ⚠️ |
| Response body validation | ✅ | ✅ | ✅ | ✅ | ✅ |
| Request validation (body + params) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Response header validation | ✅ | ⚠️ | ✅ | ✅ | ✅ |
| Endpoint coverage tracking | ✅ | ❌ | ❌ | ❌ | ❌ |
| Skip-by-status-code (default 5xx) | ✅ | ❌ | ❌ | ❌ | ✅ |
| PHPUnit integration | ✅ | ✅ | ❌ | ⚠️ | ✅ |
| Pest plugin | ❌ | ❌ | ❌ | ❌ | ❌ |
| Laravel auto-assert | ✅ | ✅ | ❌ | ❌ | ✅ |
| Symfony HttpFoundation | ❌ | ❌ | ⚠️ | ✅ | ❌ |
External $ref auto-resolution |
✅ | ✅ | ✅ | ✅ | ✅ |
| YAML spec loading | ✅ | ⚠️ | ✅ | ✅ | ✅ |
| Auto-inject dummy bearer | ✅ | ❌ | ❌ | ❌ | ❌ |
| GitHub Step Summary output | ✅ | ❌ | ❌ | ❌ | ❌ |
Legend: ✅ fully supported · ⚠️ partial, delegated to an underlying library, or not explicitly documented · ❌ not supported
Methodology: Cells reflect what each library's public documentation and source explicitly guarantee as of 2026-04-25. Competitor versions checked: Spectator v2.2.0, league/openapi-psr7-validator v0.22, osteel/openapi-httpfoundation-testing v0.14, kirschbaum-development/laravel-openapi-validator v2.0.
Requirements
- PHP 8.2+
- PHPUnit 11, 12, or 13
- A PSR-18 HTTP client + PSR-17 request factory (e.g. Guzzle, Symfony HttpClient) — only required when resolving HTTP(S)
$refs
Installation
composer require --dev studio-design/openapi-contract-testing
YAML specs require
symfony/yaml. It is listed undersuggestso it isn't installed automatically. If your spec is JSON, you can skip this. If your spec is.yaml/.yml, add it explicitly:composer require --dev symfony/yamlWithout it, the loader throws
InvalidOpenApiSpecExceptionwith a clear "requires symfony/yaml" message the first time it tries to read a YAML file.
Setup
1. Provide your OpenAPI spec
Internal $ref (#/components/schemas/...) and local-filesystem $ref (./schemas/pet.yaml, ../shared/error.json) are resolved automatically — no pre-bundling required. Point the loader at your spec's entry file:
openapi/
├── root.yaml # paths reference ./schemas/*.yaml
└── schemas/
├── pet.yaml
└── error.json
HTTP(S) $ref (https://example.com/schemas/pet.yaml) is opt-in for security and CI predictability — see HTTP $ref resolution below. If you prefer the legacy bundled-spec workflow, the loader still accepts the output of npx @redocly/cli bundle --dereferenced unchanged.
2. Configure PHPUnit extension
Add the coverage extension to your phpunit.xml:
<extensions> <bootstrap class="Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension"> <parameter name="spec_base_path" value="openapi/bundled"/> <parameter name="strip_prefixes" value="/api"/> <parameter name="specs" value="front,admin"/> </bootstrap> </extensions>
| Parameter | Required | Default | Description |
|---|---|---|---|
spec_base_path |
Yes* | — | Path to bundled spec directory (relative paths resolve from getcwd()) |
strip_prefixes |
No | [] |
Comma-separated prefixes to strip from request paths (e.g., /api) |
specs |
No | front |
Comma-separated spec names for coverage tracking |
output_file |
No | — | File path to write Markdown coverage report (relative paths resolve from getcwd()) |
console_output |
No | default |
Console output mode: default, all, or uncovered_only (overridden by OPENAPI_CONSOLE_OUTPUT env var) |
*Not required if you call OpenApiSpecLoader::configure() manually.
3. Use in tests
With Laravel (recommended)
Publish the config file:
php artisan vendor:publish --tag=openapi-contract-testing
This creates config/openapi-contract-testing.php:
return [ 'default_spec' => '', // e.g., 'front' // Maximum number of validation errors to report per response. // 0 = unlimited (reports all errors). 'max_errors' => 20, // Automatically validate every TestResponse produced by Laravel HTTP // helpers (get(), post(), etc.) against the OpenAPI spec. Defaults to // false for backward compatibility. 'auto_assert' => false, // Regex patterns (without delimiters or anchors) matched against the // response status code. Matching codes short-circuit body validation — // the test passes and the endpoint is still recorded as covered. // Defaults to skipping every 5xx. Set to [] to validate every code. 'skip_response_codes' => ['5\d\d'], ];
Set default_spec to your spec name, then use the trait — no per-class override needed:
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; class GetPetsTest extends TestCase { use ValidatesOpenApiSchema; public function test_list_pets(): void { $response = $this->get('/api/v1/pets'); $response->assertOk(); $this->assertResponseMatchesOpenApiSchema($response); } }
To use a different spec for a specific test class, add the #[OpenApiSpec] attribute:
use Studio\OpenApiContractTesting\OpenApiSpec; use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; #[OpenApiSpec('admin')] class AdminGetUsersTest extends TestCase { use ValidatesOpenApiSchema; // All tests in this class use the 'admin' spec }
You can also specify the spec per test method. Method-level attributes take priority over class-level:
#[OpenApiSpec('front')] class MixedApiTest extends TestCase { use ValidatesOpenApiSchema; public function test_front_endpoint(): void { // Uses 'front' from class-level attribute } #[OpenApiSpec('admin')] public function test_admin_endpoint(): void { // Uses 'admin' from method-level attribute (overrides class) } }
Resolution priority (highest to lowest) — the first match wins:
| # | Layer | Typical use |
|---|---|---|
| 1 | Method-level #[OpenApiSpec] attribute |
Per-test override inside a class whose other tests target a different spec |
| 2 | Class-level #[OpenApiSpec] attribute |
Default spec for a class whose tests all hit the same API surface |
| 3 | openApiSpec() method override |
Class-specific spec without the attribute (e.g. dynamically chosen at runtime) |
| 4 | config('openapi-contract-testing.default_spec') |
Project-wide default set once in config/openapi-contract-testing.php |
Concrete example where all four layers are populated (class name differs from the earlier MixedApiTest example so both snippets can coexist in one project):
use Studio\OpenApiContractTesting\OpenApiSpec; // config/openapi-contract-testing.php → ['default_spec' => 'front'] (layer 4) #[OpenApiSpec('admin')] // layer 2 class AllLayersPriorityTest extends TestCase { use ValidatesOpenApiSchema; protected function openApiSpec(): string // layer 3 { return 'internal'; } public function test_uses_class_attr(): void { // Resolves to 'admin' — layer 2 wins over layer 3 and layer 4. } #[OpenApiSpec('experimental')] // layer 1 public function test_uses_method_attr(): void { // Resolves to 'experimental' — layer 1 wins over all lower layers. } }
If every layer is absent (no attributes, openApiSpec() not overridden, and default_spec empty), the assertion fails with a message that points at each opt-in location:
openApiSpec() must return a non-empty spec name, but an empty string was returned.
Either add #[OpenApiSpec('your-spec')] to your test class or method,
override openApiSpec() in your test class, or set the "default_spec" key
in config/openapi-contract-testing.php.
Note:
openApiSpec()remains the original extension hook and is fully backward-compatible — overriding it works exactly as before.
Framework-agnostic
You can use the #[OpenApiSpec] attribute with the OpenApiSpecResolver trait in any PHPUnit test:
use Studio\OpenApiContractTesting\OpenApiSpec; use Studio\OpenApiContractTesting\OpenApiSpecResolver; use Studio\OpenApiContractTesting\OpenApiResponseValidator; #[OpenApiSpec('front')] class GetPetsTest extends TestCase { use OpenApiSpecResolver; public function test_list_pets(): void { $specName = $this->resolveOpenApiSpec(); // 'front' $validator = new OpenApiResponseValidator(); $result = $validator->validate( specName: $specName, method: 'GET', requestPath: '/api/v1/pets', statusCode: 200, responseBody: $decodedJsonBody, responseContentType: 'application/json', ); $this->assertTrue($result->isValid(), $result->errorMessage()); } }
Or without the attribute, pass the spec name directly:
use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiSpecLoader; // Configure once (e.g., in bootstrap) OpenApiSpecLoader::configure(__DIR__ . '/openapi/bundled', ['/api']); // In your test $validator = new OpenApiResponseValidator(); $result = $validator->validate( specName: 'front', method: 'GET', requestPath: '/api/v1/pets', statusCode: 200, responseBody: $decodedJsonBody, responseContentType: 'application/json', // optional: enables content negotiation ); $this->assertTrue($result->isValid(), $result->errorMessage());
Controlling the number of validation errors
By default, up to 20 validation errors are reported per response. You can change this via the constructor:
// Report up to 5 errors $validator = new OpenApiResponseValidator(maxErrors: 5); // Report all errors (unlimited) $validator = new OpenApiResponseValidator(maxErrors: 0); // Stop at first error (pre-v0.x default) $validator = new OpenApiResponseValidator(maxErrors: 1);
For Laravel, set the max_errors key in config/openapi-contract-testing.php.
Skipping responses by status code
Production error responses (typically 5xx) are often deliberately left out of the OpenAPI spec. Without special handling, a test that hits a 500 would fail twice: once from the underlying bug, and again from "Status code 500 not defined". To avoid that noise, every 5xx response is skipped by default — body validation is not performed, the assertion passes, and the endpoint is still recorded as covered.
Override via skip_response_codes in config/openapi-contract-testing.php:
return [ // Default — skip all 5xx 'skip_response_codes' => ['5\d\d'], // Widen to also skip all 4xx 'skip_response_codes' => ['4\d\d', '5\d\d'], // Disable entirely — validate every status code 'skip_response_codes' => [], ];
Or pass directly to OpenApiResponseValidator:
$validator = new OpenApiResponseValidator(skipResponseCodes: ['5\d\d']);
Notes:
- Patterns are regex strings without
/delimiters or^$anchors; they are anchored automatically, so5\d\dmatches exactly500–599(not5000). - The skip check sits between the "path / method not in spec" checks and the "status code not defined" / schema-validation checks. A skipped code therefore suppresses both status-code failure modes (undocumented code AND body mismatch for a documented code), but typos in the request path or method still fail loudly.
- Skipped endpoints count as covered — the endpoint was exercised, just not schema-validated. Coverage semantics here match how non-JSON content types and schema-less
204responses are handled, butOpenApiValidationResult::isSkipped()returnstrueonly for status-code skips; the other no-body-validation branches still return a plainsuccess(). OpenApiValidationResult::isSkipped()is exposed for callers who want to distinguish a skip from a genuine success.skipReason()identifies the matched pattern.outcome()returns anOpenApiValidationOutcomeenum (Success/Failure/Skipped) for callers who want exhaustivematchhandling instead of two bool predicates.- Observability trade-off: a real regression that causes an unrelated
500will not fail this assertion. Keep your HTTP-level assertions ($response->assertOk(), status-code expectations in the test) alongside the contract check so a stray 5xx still surfaces — the contract assertion alone is not a substitute for status-code assertions on happy paths. - Coverage signal: skipped responses surface as their own row inside each endpoint's response table —
⚠(:warning:in Markdown) on the per-(status, content-type)line, with the matched skip pattern shown inline. The endpoint marker becomes◐(partial) when other responses are still validated, or stays✓only when every declared response is covered. The response-level rate (responseCovered / responseTotal) excludes skipped definitions, so a happy-path regression that silently returns500in every test no longer hides behind a 100% endpoint count.skipReason()is available on eachOpenApiValidationResultfor callers who want to log the matched pattern from a custom renderer.
Auto-assert every response
Forgetting $this->assertResponseMatchesOpenApiSchema($response) in a test means the contract is silently unchecked. Enable auto_assert to validate every response produced by Laravel's HTTP helpers automatically — just include the trait:
// config/openapi-contract-testing.php return [ 'default_spec' => 'front', 'auto_assert' => true, ];
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; class GetPetsTest extends TestCase { use ValidatesOpenApiSchema; public function test_list_pets(): void { // Contract is checked automatically — no explicit assert call needed. $this->get('/api/v1/pets')->assertOk(); } }
Notes:
- Defaults to
falseso existing test suites keep their explicit-assert behavior. - Auto-assert hooks into
MakesHttpRequests::createTestResponse(). Responses you construct manually (outside$this->get(),$this->post(), etc.) are not touched. - Idempotency is keyed on the
(spec, method, path)tuple. CallingassertResponseMatchesOpenApiSchema($response)after auto-assert with the matching signature is a no-op. Calling it with a differentmethod/path— or a different#[OpenApiSpec]— runs validation again. - When auto-assert fails, the exception is thrown from inside
$this->get(...), so any chained assertion on the same line ($this->get(...)->assertOk()) will not run. This is usually what you want — the schema failure takes precedence over status-code checks. auto_assertaccepts boolean-compatible values (true/false/"1"/"0"/"true"/"false") so'auto_assert' => env('OPENAPI_AUTO_ASSERT')works. Unrecognized values fail the test loudly with a clear message, not silently.- Streamed responses (
StreamedResponse, binary downloads) causegetContent()to returnfalse, which fails auto-assert with a clear message. If you useauto_assert=trueon tests that exercise streams, scope the config change per-test or fall back to explicit manual asserts.
Opting out with #[SkipOpenApi]
Some tests intentionally return responses that violate the spec (error-injection tests, experimental endpoints with a not-yet-finalized contract, etc.). For these, use the #[SkipOpenApi] attribute to opt out of auto-assert without turning the feature off globally:
use Studio\OpenApiContractTesting\SkipOpenApi; class ExperimentalApiTest extends TestCase { use ValidatesOpenApiSchema; #[Test] #[SkipOpenApi(reason: 'endpoint is behind an experimental flag')] public function test_experimental_endpoint(): void { $this->get('/v1/experimental'); // auto-assert is skipped } }
The attribute can also be applied at the class level to skip every method in that class. A method-level #[SkipOpenApi] fully shadows the class-level one — only the method-level attribute (and its reason) is inspected.
Notes:
#[SkipOpenApi]suppresses auto-assert only. Explicit calls toassertResponseMatchesOpenApiSchema()still run — the assertion is the user's direct intent.- When auto-assert is skipped and no explicit assertion is made, no coverage is recorded for that request (the endpoint is treated as uncovered in the report). If you call
assertResponseMatchesOpenApiSchema()explicitly on a skipped test, validation runs and coverage is recorded as usual. - If a test is marked
#[SkipOpenApi]and still callsassertResponseMatchesOpenApiSchema()explicitly, an advisory warning is written toSTDERRand a user deprecation is raised to flag the contradictory intent. The assertion is not suppressed — fix the cause by removing either the attribute or the explicit call. - The attribute is resolved via reflection on the direct class only; a class-level
#[SkipOpenApi]on an abstract parent is not inherited by subclasses. Apply the attribute on each concrete test class (or per method) instead.
Per-request skip with withoutValidation()
When only a single request should skip validation — e.g., exercising a legacy endpoint during a staged migration — use the fluent withoutValidation() API instead of annotating the whole method:
class PetApiTest extends TestCase { use ValidatesOpenApiSchema; public function test_legacy_endpoint(): void { // Only this HTTP call skips validation. $this->withoutValidation() ->get('/v1/pets/legacy') ->assertOk(); // The next call is validated as usual. $this->get('/v1/pets')->assertOk(); } }
Three scopes are available:
withoutValidation()— skip both request and response validationwithoutResponseValidation()— skip response validation onlywithoutRequestValidation()— skip request validation only (active whenauto_validate_requestis on)
Notes:
- The flag applies to exactly one HTTP call. It is consumed on the next
$this->get()/$this->post()/ etc., then automatically resets — a second consecutive call validates normally. - Scoped to auto-assert only, like
#[SkipOpenApi]. An explicitassertResponseMatchesOpenApiSchema()call still runs regardless of the flag. - No coverage is recorded for the skipped request.
- Each method returns
$this, so both$this->withoutValidation()->get(...)and the step-by-step form ($this->withoutValidation(); $this->get(...);) work.
Per-request status-code skip with skipResponseCode()
withoutValidation() is all-or-nothing for a request. When you only need to suppress a specific status code — e.g., a flaky 404 while a fixture is being repaired — skipResponseCode() adds a one-off skip on top of the config-level set:
class PetApiTest extends TestCase { use ValidatesOpenApiSchema; public function test_endpoint_returning_a_documented_404(): void { // Only this call treats 404 as a skip; other calls keep the default behavior. $this->skipResponseCode(404) ->get('/v1/pets/missing') ->assertNotFound(); } }
Argument shapes:
int— exact match, anchored.skipResponseCode(500)matches"500"only, never"5000"or"50".string— regex pattern (anchored automatically).skipResponseCode('4\d\d')matches the entire4xxrange.array— expanded one level. Mixed types are allowed:skipResponseCode([404, '5\d\d']).- Variadic — multiple positional arguments work too:
skipResponseCode(404, 500, '5\d\d').
Notes:
- Merged with
skip_response_codesfrom config — per-request codes ADD to the config set rather than replacing it. With the default config,skipResponseCode(404)skips both404and every5xx. - One HTTP call: same consumption model as
withoutValidation(). The codes are consumed on the next auto-assert attempt and reset, so the next call falls back to the config-level set. - Auto-assert only. Explicit
assertResponseMatchesOpenApiSchema()calls ignore per-request codes — explicit calls are the user's direct intent. - Chainable: returns
$this. Multiple chained calls accumulate ($this->skipResponseCode(404)->skipResponseCode(503)registers both).
Auto-validate every request
Request-side contract drift (missing query params, body-shape divergence, absent security headers) goes undetected unless the test explicitly checks for it. Enable auto_validate_request to run OpenApiRequestValidator against every request Laravel's HTTP helpers dispatch:
// config/openapi-contract-testing.php return [ 'default_spec' => 'front', 'auto_validate_request' => true, 'auto_inject_dummy_bearer' => true, // optional — see below ];
Validation covers path / query / header parameters, request body (JSON Schema), and security schemes. Failures raise a PHPUnit assertion error from inside the HTTP call, exactly like auto_assert.
Notes:
- Independent of
auto_assert— either side can be enabled on its own. Both default tofalsefor backward compatibility. withoutRequestValidation()and#[SkipOpenApi]both opt a single call (or a whole test) out of request validation, with the same per-request semantics already documented for response-side auto-assert.auto_validate_requestaccepts boolean-compatible values ("true","1", etc.) likeauto_assert. Unrecognized values fail the test loudly.- Coverage is recorded for every matched request path, so enabling auto-validate-request without auto-assert still lights up your coverage report.
Auto-inject dummy bearer
When auto_validate_request=true, endpoints whose spec declares bearerAuth fail the security check unless the test supplies an Authorization: Bearer … header. For test suites that authenticate via actingAs() or middleware bypass — and therefore never set the header — set auto_inject_dummy_bearer=true to auto-inject Authorization: Bearer test-token into the validator's view of the request:
class SecureEndpointTest extends TestCase { use ValidatesOpenApiSchema; public function test_secure_endpoint(): void { $this->actingAs(User::factory()->create()); // No header set, but auto-inject lets request validation pass. $this->get('/v1/secure/bearer')->assertOk(); } }
Notes:
- View-only rewrite: the Symfony
Requestitself is not modified; Laravel has already dispatched by the time the trait runs. The inject exists purely to prevent the security check from false-failing. - Bearer only:
apiKeyandoauth2endpoints are not affected (the header name forapiKeyis arbitrary per spec;oauth2is classified as unsupported in phase 1 anyway). - Never overrides user values: if the test already set an
Authorizationheader (in any case), the user's value wins. - Requires
auto_validate_request=true— the inject is a sub-feature of request validation. Setting the inject flag alone has no effect.
Coverage Report
After running tests, the PHPUnit extension prints a coverage report. The output format is controlled by the console_output parameter (or OPENAPI_CONSOLE_OUTPUT environment variable).
Coverage is tracked at (method, path, statusCode, contentType) granularity: a GET /v1/pets test that only exercises 200 application/json does not count 404 or application/problem+json as covered. Per-endpoint markers reflect the resolved state across all declared response definitions:
| Marker | Meaning |
|---|---|
✓ / :white_check_mark: |
All declared (status, content-type) pairs validated |
◐ / :large_orange_diamond: |
Some pairs validated, others uncovered |
⚠ / :warning: |
Pair was skipped (e.g. 5XX matched the default skip pattern) |
✗ / :x: |
No pair validated for this endpoint |
· / :information_source: |
Endpoint reached via request-validation but no response asserted |
The report also breaks the coverage rate into two numbers — the strict endpoint rate (all declared responses validated) and the response-level rate (responseCovered / responseTotal).
default mode (default)
Shows endpoint summary lines only:
OpenAPI Contract Test Coverage
==================================================
[front] endpoints: 12/45 fully covered (26.7%), 8 partial, 25 uncovered
responses: 38/120 covered (31.7%), 4 skipped, 78 uncovered
--------------------------------------------------
Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type
✓ GET /v1/pets (3/3 responses)
◐ POST /v1/pets (1/2 responses)
◐ DELETE /v1/pets/{petId} (1/2 responses, 1 skipped)
✗ PUT /v1/pets/{petId} (0/2 responses)
Endpoint markers come from a fixed set:
✓all-covered,◐partial (any combination of validated, skipped, uncovered short of full coverage),·request-only,✗uncovered. The⚠marker is reserved for per-response sub-rows (skipped responses), never for endpoint summary lines.
all mode
Shows endpoint summaries with per-response sub-rows. Sub-row whitespace is illustrative — the renderer pads statusKey to 5 chars and contentTypeKey to 32 chars:
[front] endpoints: 12/45 fully covered (26.7%), 8 partial, 25 uncovered
responses: 38/120 covered (31.7%), 4 skipped, 78 uncovered
--------------------------------------------------
Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type
✓ GET /v1/pets (3/3 responses)
✓ 200 application/json [12]
✓ 400 application/problem+json [1]
✓ 422 Application/Problem+JSON [1]
◐ POST /v1/pets (1/2 responses)
✓ 201 application/json [3]
✗ 422 application/problem+json uncovered
◐ DELETE /v1/pets/{petId} (1/2 responses, 1 skipped)
✓ 204 * [2]
⚠ 5XX * skipped: status 503 matched skip pattern 5\d\d
uncovered_only mode
Shows sub-rows only for partial / uncovered endpoints, keeping fully-covered ones compact:
[front] endpoints: 12/45 fully covered (26.7%), 8 partial, 25 uncovered
responses: 38/120 covered (31.7%), 4 skipped, 78 uncovered
--------------------------------------------------
Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type
✓ GET /v1/pets (3/3 responses)
◐ POST /v1/pets (1/2 responses)
✗ 422 application/problem+json uncovered
✗ PUT /v1/pets/{petId} (0/2 responses)
✗ 200 application/json uncovered
✗ 404 application/problem+json uncovered
You can set the mode via phpunit.xml:
<parameter name="console_output" value="uncovered_only"/>
Or via environment variable (takes priority over phpunit.xml):
OPENAPI_CONSOLE_OUTPUT=uncovered_only vendor/bin/phpunit
CI Integration
GitHub Actions Step Summary
When running in GitHub Actions, the extension automatically detects the GITHUB_STEP_SUMMARY environment variable and appends a Markdown coverage report to the job summary. No configuration needed.
Note: Both features are independent — when running in GitHub Actions with
output_fileconfigured, the Markdown report is written to both the file and the Step Summary.
Markdown output file
Use the output_file parameter to write a Markdown report to a file. This is useful for posting coverage as a PR comment:
<extensions> <bootstrap class="Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension"> <parameter name="spec_base_path" value="openapi/bundled"/> <parameter name="specs" value="front,admin"/> <parameter name="output_file" value="coverage-report.md"/> </bootstrap> </extensions>
You can also use the OPENAPI_CONSOLE_OUTPUT environment variable in CI to show uncovered endpoints in the job log:
- name: Run tests (show uncovered endpoints) run: vendor/bin/phpunit env: OPENAPI_CONSOLE_OUTPUT: uncovered_only
Example GitHub Actions workflow step to post the report as a PR comment:
- name: Run tests run: vendor/bin/phpunit - name: Post coverage comment if: github.event_name == 'pull_request' && hashFiles('coverage-report.md') != '' uses: marocchino/sticky-pull-request-comment@v2 with: path: coverage-report.md
HTTP $ref resolution (opt-in)
Local $ref is resolved automatically. HTTP(S) $ref is disabled by default: a spec containing $ref: 'https://example.com/pet.yaml' rejects with RemoteRefDisallowed until you opt in. This keeps tests offline-by-default and prevents an attacker-controlled spec from making the test runner reach arbitrary URLs.
To enable HTTP refs, install a PSR-18 client + PSR-17 request factory and pass them along with allowRemoteRefs: true:
# Install your preferred PSR-18 client (Guzzle 7+ shown; Symfony HttpClient + adapter, Buzz, # or any other PSR-18 implementation works the same). composer require --dev guzzlehttp/guzzle
use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Studio\OpenApiContractTesting\OpenApiSpecLoader; OpenApiSpecLoader::configure( basePath: 'openapi/', httpClient: new Client(), // PSR-18 ClientInterface requestFactory: new HttpFactory(), // PSR-17 RequestFactoryInterface allowRemoteRefs: true, );
The library does not bundle an HTTP client — pick whichever your project already uses. (Guzzle 7+ implements PSR-18 directly; Guzzle 6 needs an adapter.)
Misconfiguration is caught early:
| Setup | Result |
|---|---|
allowRemoteRefs: true without $httpClient / $requestFactory |
InvalidArgumentException at configure() |
$httpClient set but allowRemoteRefs: false |
InvalidArgumentException at configure() (silent misuse impossible) |
allowRemoteRefs: true + client + ref to URL that 4xx/5xx |
InvalidOpenApiSpecException with reason RemoteRefFetchFailed |
allowRemoteRefs: true + client + ref to URL that 3xx |
RemoteRefFetchFailed with redirect target — configure your PSR-18 client to follow redirects, or use the canonical URL |
allowRemoteRefs: true + client + ref to URL with no detectable format |
reason UnsupportedExtension (URL extension or Content-Type header is required) |
Format detection prefers the URL's filename extension (.json / .yaml / .yml) and falls back to the response's Content-Type (application/json, application/*+json, application/yaml, text/yaml, etc.). URLs without a recognisable extension still work as long as the server sets a usable Content-Type.
Inside an HTTP-loaded document, relative $refs resolve against the URL per RFC 3986: a $ref: './pet.yaml' inside https://example.com/openapi.json fetches https://example.com/pet.yaml.
OpenAPI 3.0 vs 3.1
The package auto-detects the OAS version from the openapi field and handles schema conversion accordingly:
| Feature | 3.0 handling | 3.1 handling |
|---|---|---|
nullable: true |
Converted to type array ["string", "null"] |
Not applicable (uses type arrays natively) |
prefixItems |
N/A | Converted to items array (Draft 07 tuple) |
$dynamicRef / $dynamicAnchor |
N/A | Removed (not in Draft 07) |
examples (array) |
N/A | Removed (OAS extension) |
readOnly / writeOnly |
Semantic enforcement (see below). Forbidden properties become boolean false subschemas; the keyword is dropped as OAS-only on surviving properties |
Semantic enforcement (see below). Forbidden properties become boolean false subschemas; the keyword is preserved on surviving properties (valid in Draft 07) |
readOnly / writeOnly enforcement
Both validators apply OpenAPI's asymmetric semantics instead of letting the keywords pass as no-ops:
- Response validation (
OpenApiResponseValidator, Laravel trait): any property markedwriteOnly: truemust not appear in the response body. If it does, validation fails with the offending property named in the error. AwriteOnly + requiredentry is treated as absent on the response side, so a compliant response that omits the property still validates. - Request validation (
OpenApiRequestValidator): any property markedreadOnly: truemust not appear in the request body.readOnly + requiredis treated as absent on the request side, so a compliant request that omits the property still validates.
Detection looks at each property schema's own top-level readOnly / writeOnly; markers nested inside the property's allOf / oneOf / anyOf children are not enforced in the current release.
API Reference
OpenApiResponseValidator
Main validator class. Validates a response body against the spec.
The constructor accepts a maxErrors parameter (default: 20) that limits how many validation errors the underlying JSON Schema validator collects. Use 0 for unlimited, 1 to stop at the first error.
The optional responseContentType parameter enables content negotiation: when provided, non-JSON content types (e.g., text/html) are checked for spec presence only, while JSON-compatible types proceed to full schema validation.
$validator = new OpenApiResponseValidator(maxErrors: 20); $result = $validator->validate( specName: 'front', method: 'GET', requestPath: '/api/v1/pets/123', statusCode: 200, responseBody: ['id' => 123, 'name' => 'Fido'], responseContentType: 'application/json', ); $result->outcome(); // OpenApiValidationOutcome (Success | Failure | Skipped) $result->isValid(); // bool (true for both successes AND skipped results) $result->isSkipped(); // bool (true when the status code matched skip_response_codes) $result->errors(); // string[] $result->errorMessage(); // string (joined errors) $result->matchedPath(); // ?string (e.g., '/v1/pets/{petId}') $result->skipReason(); // ?string (non-null when skipped)
Prefer outcome() when you need to distinguish all three states explicitly — PHPStan enforces match exhaustiveness, so adding a future outcome cannot silently slip past a caller:
use PHPUnit\Framework\AssertionFailedError; use Studio\OpenApiContractTesting\OpenApiValidationOutcome; match ($result->outcome()) { OpenApiValidationOutcome::Success => null, // schema matched OpenApiValidationOutcome::Failure => throw new AssertionFailedError($result->errorMessage()), OpenApiValidationOutcome::Skipped => logger()->info('skipped', ['reason' => $result->skipReason()]), };
OpenApiSpecLoader
Manages spec loading and configuration.
OpenApiSpecLoader::configure('/path/to/bundled/specs', ['/api']); $spec = OpenApiSpecLoader::load('front'); OpenApiSpecLoader::reset(); // For testing
OpenApiCoverageTracker
Tracks which endpoints have been exercised, at (method, path, statusCode, contentType) granularity. The Laravel trait records via the tracker automatically; framework-agnostic adapters call it directly.
// Request-side: an endpoint was reached without a response assertion OpenApiCoverageTracker::recordRequest('front', 'GET', '/v1/pets'); // Response-side: full granularity (status + content-type spec keys) OpenApiCoverageTracker::recordResponse( specName: 'front', method: 'GET', path: '/v1/pets', statusKey: '200', // spec key, or literal status when skipped contentTypeKey: 'application/json',// spec key (case preserved); null → "*" schemaValidated: true, // false → state=skipped skipReason: null, ); $coverage = OpenApiCoverageTracker::computeCoverage('front'); // [ // 'endpoints' => [...per-endpoint EndpointSummary, includes per-response sub-rows...], // 'endpointTotal' => 45, // 'endpointFullyCovered' => 12, // 'endpointPartial' => 8, // 'endpointUncovered' => 25, // 'responseTotal' => 120, // 'responseCovered' => 38, // 'responseSkipped' => 4, // 'responseUncovered' => 78, // ... // ]
hasAnyCoverage(spec): bool is a fast presence check. getCovered() is retained as a diagnostic shim returning array<spec, array<"METHOD path", true>>. See CHANGELOG.md for the migration from the pre-#111 endpoint-level shape.
Development
composer install # Run tests vendor/bin/phpunit # Static analysis vendor/bin/phpstan analyse # Code style vendor/bin/php-cs-fixer fix vendor/bin/php-cs-fixer fix --dry-run --diff # Check only
License
MIT License. See LICENSE for details.