studio-design/openapi-contract-testing

Framework-agnostic OpenAPI 3.0/3.1 contract testing for PHPUnit with endpoint coverage tracking

Maintainers

Package info

github.com/studio-design/openapi-contract-testing

pkg:composer/studio-design/openapi-contract-testing

Statistics

Installs: 1 545

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 12

v0.13.0 2026-04-25 11:16 UTC

This package is auto-updated.

Last update: 2026-04-25 11:16:44 UTC


README

CI Latest Stable Version License

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 openapi field
  • Response validation — Validates response bodies against JSON Schema (Draft 07 via opis/json-schema). Supports application/json and any +json content type (e.g., application/problem+json)
  • Content negotiation — Accepts the actual response Content-Type to 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 fluent skipResponseCode() 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 under suggest so 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/yaml

Without it, the loader throws InvalidOpenApiSpecException with 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, so 5\d\d matches exactly 500599 (not 5000).
  • 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 204 responses are handled, but OpenApiValidationResult::isSkipped() returns true only for status-code skips; the other no-body-validation branches still return a plain success().
  • OpenApiValidationResult::isSkipped() is exposed for callers who want to distinguish a skip from a genuine success. skipReason() identifies the matched pattern. outcome() returns an OpenApiValidationOutcome enum (Success / Failure / Skipped) for callers who want exhaustive match handling instead of two bool predicates.
  • Observability trade-off: a real regression that causes an unrelated 500 will 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 returns 500 in every test no longer hides behind a 100% endpoint count. skipReason() is available on each OpenApiValidationResult for 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 false so 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. Calling assertResponseMatchesOpenApiSchema($response) after auto-assert with the matching signature is a no-op. Calling it with a different method/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_assert accepts 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) cause getContent() to return false, which fails auto-assert with a clear message. If you use auto_assert=true on 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 to assertResponseMatchesOpenApiSchema() 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 calls assertResponseMatchesOpenApiSchema() explicitly, an advisory warning is written to STDERR and 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 validation
  • withoutResponseValidation() — skip response validation only
  • withoutRequestValidation() — skip request validation only (active when auto_validate_request is 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 explicit assertResponseMatchesOpenApiSchema() 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 entire 4xx range.
  • 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_codes from config — per-request codes ADD to the config set rather than replacing it. With the default config, skipResponseCode(404) skips both 404 and every 5xx.
  • 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 to false for 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_request accepts boolean-compatible values ("true", "1", etc.) like auto_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 Request itself 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: apiKey and oauth2 endpoints are not affected (the header name for apiKey is arbitrary per spec; oauth2 is classified as unsupported in phase 1 anyway).
  • Never overrides user values: if the test already set an Authorization header (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_file configured, 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 marked writeOnly: true must not appear in the response body. If it does, validation fails with the offending property named in the error. A writeOnly + required entry is treated as absent on the response side, so a compliant response that omits the property still validates.
  • Request validation (OpenApiRequestValidator): any property marked readOnly: true must not appear in the request body. readOnly + required is 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.