# Gesso
> Every page listed in llms.txt, concatenated in the same order. Each page begins with its Source URL.
Source: https://studio-design.github.io/gesso/quickstarts/core.md
# Core / PHPUnit quickstart
```bash
composer require --dev "studio-design/gesso:^2.0"
```
Register the extension in `phpunit.xml`. It configures the spec loader and prints
the coverage report; without it the first validation call throws
`OpenApiSpecLoader base path not configured`:
```xml
```
Copy the minimal [`examples/core`](https://github.com/studio-design/gesso/tree/main/examples/core) project. Its test validates a JSON response without a framework adapter. The validator records the coverage observation itself, so this is the whole test — the endpoint coverage table appears without any tracker call:
```php
$result = (new OpenApiResponseValidator(new StrictRequiredTracker()))->validate(
'petstore', 'GET', '/pets', 200,
[['id' => 1, 'name' => 'Fido']],
'application/json',
);
self::assertTrue($result->isValid(), $result->errorMessage());
```
Run it:
```bash
composer test
```
The PHPUnit extension prints the endpoint coverage report after the passing test. For applications using PSR-7 messages, continue with the [PSR-7 guide](../psr7.md) — `OpenApiPsr7Validator` records coverage itself.
---
Source: https://studio-design.github.io/gesso/quickstarts/laravel.md
# Laravel quickstart
```bash
composer require --dev "studio-design/gesso:^2.0"
php artisan vendor:publish --tag=gesso
```
Register the extension in `phpunit.xml`. This is what configures the spec loader
for the runtime validator; `gesso.spec_base_path` in the published
`config/gesso.php` does not — it only feeds the `gesso:routes` and
`gesso:stubs` Artisan commands:
```xml
```
Set `default_spec` to `petstore`, add `ValidatesOpenApiSchema` to your base test case, then assert a normal Laravel response:
```php
$response = $this->getJson('/pets');
$response->assertOk();
$this->assertResponseMatchesOpenApiSchema($response);
```
The complete [`examples/laravel`](https://github.com/studio-design/gesso/tree/main/examples/laravel) fixture installs and runs in CI:
```bash
composer test
```
Its second test enables `auto_assert` and `auto_validate_request`, demonstrating validation without an explicit assertion. The PHPUnit extension prints coverage for both tests.
Already generating your spec with Scramble? Follow the
[Scramble integration recipe](../recipes/scramble.md) to validate generated
contracts, cover 401/404/422 responses, and gate untested contract changes in CI.
---
Source: https://studio-design.github.io/gesso/quickstarts/symfony.md
# Symfony quickstart
```bash
composer require --dev "studio-design/gesso:^2.0" symfony/http-foundation symfony/http-kernel
```
Register the extension in `phpunit.xml`. Gesso finds spec files through the
PHPUnit extension, not a Symfony bundle:
```xml
```
Mix `Studio\Gesso\Symfony\OpenApiAssertions` into a PHPUnit or `WebTestCase`
class and name the spec with `#[OpenApiSpec]` (or override `openApiSpec()`):
```php
use PHPUnit\Framework\TestCase;
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Symfony\OpenApiAssertions;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[OpenApiSpec('petstore')]
class PetsApiTest extends TestCase
{
use OpenApiAssertions;
public function test_pets_index(): void
{
$request = Request::create('/pets', 'GET');
$response = new Response('[{"id":1,"name":"Fido"}]', 200, [
'Content-Type' => 'application/json',
]);
$this->assertResponseMatchesOpenApiSchema($request, $response);
}
}
```
The trait adds three assertions: `assertResponseMatchesOpenApiSchema($request, $response)`,
`assertRequestMatchesOpenApiSchema($request)`, and
`assertClientMatchesOpenApiSchema($client)`, which runs both against the last
client call.
Run the CI-tested [`examples/symfony`](https://github.com/studio-design/gesso/tree/main/examples/symfony) fixture:
```bash
composer test
```
For a full application, call `assertClientMatchesOpenApiSchema($client)` after `KernelBrowser::request()`. Both forms record coverage.
---
Source: https://studio-design.github.io/gesso/quickstarts/pest.md
# Pest quickstart
```bash
composer require --dev "studio-design/gesso:^2.0" pestphp/pest
```
Pest runs on PHPUnit, so the spec loader comes from the same extension. Register
it in `phpunit.xml` — without it the first expectation throws
`OpenApiSpecLoader base path not configured`:
```xml
```
Use the automatically registered expectation with a Laravel response:
```php
expect($this->getJson('/pets'))->toMatchOpenApiResponseSchema();
```
The runnable [`examples/pest`](https://github.com/studio-design/gesso/tree/main/examples/pest) project includes response and request expectations and is executed in CI. See the [Pest guide](../pest-plugin.md) for setup and supported expectation arguments.
---
Source: https://studio-design.github.io/gesso/doctor.md
# Doctor command
`gesso doctor` answers one focused question before the test suite starts: can this package load and enforce the selected OpenAPI contract as configured?
It is not a replacement for a semantic or style linter such as Spectral or Redocly.
## Scope
`gesso doctor` is a preflight for a local OpenAPI file. It confirms Gesso can load and enforce the document — version and dialect, references, warned-about keywords, structurally valid operations — and exits non-zero when it can't. It does not fetch a deployed spec URL, and it does not compare the checked-in spec against what a running service returns. `--allow-remote-refs` only covers `$ref` targets from the local entry document.
## Basic usage
```bash
vendor/bin/gesso doctor \
--spec=openapi/front.yaml \
--strip-prefix=/api
```
The command checks file readability and parser availability, the declared OpenAPI version and JSON Schema dialect, internal and external references, schema keywords that the validator warns about, and structurally valid operations, request bodies, and response definitions. It also reports recognized features that are intentionally not enforced.
Request-body checks share the runtime validator's structural rules for
`requestBody`, `content`, media-type entries, `schema`, `itemSchema`, and request
`encoding` maps and entries. Doctor reports all malformed nodes as `structure`
errors, including media types a particular request would not select and
operations without responses in OpenAPI 3.1/3.2. These checks do not turn doctor
into a complete OpenAPI linter: for compatibility, missing or null request-body
and request-content nodes retain the runtime behavior, and response-side
`encoding` is not inspected.
JSON and YAML are supported. YAML requires the optional `symfony/yaml` package, just like runtime validation.
## Multiple specs
Repeat `--spec`, or provide comma-separated paths:
```bash
vendor/bin/gesso doctor \
--spec=openapi/front.yaml \
--spec=openapi/admin.json \
--strip-prefix=/api
```
`--phpunit-snippet` prints the equivalent extension configuration when all entry documents share one directory. The snippet uses each entry document's filename without its extension as the configured spec name.
Local external references are confined to each entry document's directory by
default. If an entry and its shared schemas use sibling directories, pass their
narrowest trusted common parent:
```bash
vendor/bin/gesso doctor \
--spec=openapi/bundled/front.yaml \
--local-ref-root=openapi \
--phpunit-snippet
```
The generated PHPUnit snippet then uses `spec_base_path="openapi"` and the
spec name `bundled/front`. Targets that escape the canonical root through
`../`, an absolute path, or a symlink are rejected; those forms remain valid
when their canonical targets stay inside the root.
## Acknowledged unvalidatable security schemes
When the test suite acknowledges an unvalidatable security scheme by name (see [Acknowledging an unvalidatable security scheme](setup.md#acknowledging-an-unvalidatable-security-scheme)), pass the same names so the doctor report reflects the acknowledgement:
```bash
vendor/bin/gesso doctor \
--spec=openapi/front.yaml \
--acknowledge-unvalidatable-scheme=ClientBasicAuth
```
The scheme is still listed as a skipped feature, marked as acknowledged instead of prompting for a separate authentication test. A name that is not defined in `components.securitySchemes`, or that names a scheme the validator can enforce, is reported as a `configuration` warning so the acknowledged list cannot rot.
## HTTP references
Remote references remain opt-in because diagnostics must not access the network unexpectedly:
```bash
vendor/bin/gesso doctor \
--spec=openapi/root.yaml \
--allow-remote-refs \
--remote-ref-host=specs.example.com \
--remote-ref-max-bytes=10485760
```
The command automatically uses an installed Guzzle (`guzzlehttp/guzzle` plus `guzzlehttp/psr7`) or Symfony HttpClient PSR-18 implementation. Every permitted host must be listed with `--remote-ref-host`; repeat the option when a trusted contract intentionally spans hosts. A nested `$ref` that switches to an unlisted host is rejected before any request is sent. Each remote document is limited to 10 MiB by default; use `--remote-ref-max-bytes=` only when a trusted contract requires a larger bound. Without `--allow-remote-refs`, an HTTP(S) `$ref` is reported as a reference error with an actionable hint. Network failures, oversized or malformed remote documents, content-type mismatches, and reference cycles also exit non-zero.
## Installed version
`gesso --version` is a global flag on the binary, matched before any subcommand:
```bash
$ vendor/bin/gesso --version
gesso 2.4.0
```
It writes to STDOUT, writes nothing to STDERR, and exits `0`. When Composer's `InstalledVersions` metadata cannot be read it prints `gesso unknown` — the same sentinel the JSON documents emit — and still exits `0`, so a CI script or an agent probing for the version always gets an answer rather than a usage error. `gesso --help` lists it under `Global options:`.
## Machine-readable output
Use `--format=json` for CI and tooling. The top-level `schemaVersion` is currently `1` and will change if the machine-readable contract requires an incompatible revision.
```json
{
"schemaVersion": 1,
"tool": {
"name": "studio-design/gesso",
"version": "2.4.0"
},
"status": "ok",
"summary": {
"specs": 1,
"operations": 4,
"responses": 7,
"errors": 0,
"warnings": 0,
"skipped": 0
},
"specs": [],
"issues": [],
"phpunit": null
}
```
`tool` is `{ "name": "studio-design/gesso", "version": "" }`, the same block the [coverage JSON](coverage-json-schema.md) and [validation JSON](validation-json-schema.md) documents carry. `"unknown"` is emitted when Composer's `InstalledVersions` metadata is unavailable; the field is always a string.
Additive changes — a new field such as `tool`, or a new `category` slug — keep `schemaVersion` at `1`. Removals, renames, and type changes bump it.
Every issue has a stable `severity`, `category`, `spec`, `message`, and nullable `suggestion` field. Severities are:
- `error`: the contract cannot be loaded or enforced as configured;
- `warning`: validation can proceed, but the package detected a compatibility limitation;
- `skipped`: a recognized contract feature is not currently enforced and needs a separate test.
Categories currently emitted by schema version 1 are `io`, `parser`, `version`, `dialect`, `references`, `structure`, `keyword`, `feature`, `dependency`, `configuration`, `spec`, `compatibility`, and `internal`. Consumers should branch on `severity` and `category`, not the human-readable message.
## Exit codes
| Code | Meaning |
|---:|---|
| `0` | All selected specs are compatible. Warnings and skipped features may still be present. |
| `1` | At least one diagnostic error prevents reliable contract enforcement. |
| `2` | The command-line invocation is invalid, such as a missing `--spec` or unknown format. |
Treat both `1` and `2` as CI failures. Always inspect warnings and skipped features even when the exit code is `0`.
---
Source: https://studio-design.github.io/gesso/baseline.md
# Violation baseline
Adopting contract testing on an existing API usually fails at the first run:
hundreds of known spec/implementation mismatches, all failing at once. The
violation baseline converts that into a one-PR adoption, the same way PHPStan
and Psalm baselines work: record today's violations in a committed file, keep
CI green, and fail only on **new** violations. The debt stays enumerated in
one reviewable file and can be ratcheted down entry by entry.
Its counterpart for *untested* responses is the
[coverage baseline](coverage-baseline.md), which shares the same generation
command.
## Quick start
1. Point the PHPUnit extension at a baseline file:
```xml
```
2. Generate the baseline (full suite, no `--filter`):
```bash
GESSO_BASELINE_GENERATE=1 vendor/bin/phpunit
```
Every contract violation is recorded instead of failing, and the sorted,
versioned baseline is written at run end:
```text
[Gesso] Baseline written: 42 violation(s) → /path/to/gesso-baseline.json
```
3. Commit `gesso-baseline.json`. From now on every normal run loads it and
suppresses baselined failures; new violations fail as usual.
The file is deterministic — fully sorted, no timestamps — so regenerating an
unchanged contract produces a byte-identical file and diffs stay reviewable.
Its format is a versioned compatibility surface; see
[versioning.md](versioning.md#whats-covered-by-semver).
## What counts as "the same violation"
Each violation is identified by a fingerprint:
`(spec, method, path template, status, content type, category, parameter,
instance path, keyword)`. Two properties make it survive unrelated change:
- **Message wording is excluded.** Validator prose may improve between
releases without invalidating the baseline.
- **Numeric JSON Pointer segments are canonicalized to `*`.** The same schema
defect reported at `/data/0/id` and `/data/3/id` is one entry, regardless
of test-data size or ordering.
Fixed HTTP methods normalize to their canonical uppercase form (`get` and
`GET` are one entry). OpenAPI 3.2 custom `additionalOperations` methods are
case-sensitive, so `COPY` and `copy` are distinct entries — a baselined
`COPY` violation never absorbs a new `copy` violation.
Non-body violations carry the parameter / response-header / security-scheme
name plus the failing keyword, so a known `limit` violation does not absorb a
future `page` violation, and a known "`limit` missing" does not absorb a
future "`limit` has the wrong type". Violations with neither a name nor a
keyword (structural spec errors, error-boundary captures) collapse per
operation and category — a documented trade-off.
Body-decode failures (unparseable JSON, an unreadable or non-seekable PSR-7
stream) carry the synthetic `parse` keyword, so a baselined decode failure
never absorbs a genuinely empty body on the same operation — and vice
versa.
## Generating under parallel runners
Under paratest or Pest `--parallel`, each worker sees only its slice of the
suite, so no single worker may write the baseline file. Generation instead
follows the same two-step workflow as [parallel coverage](parallel.md): each
worker stages its collected fingerprints in its sidecar (the `baseline` key), and
the merge step unions them into the committed file:
```bash
# 1. Run the parallel suite in generation mode — failures are demoted,
# fingerprints ride the worker sidecars.
GESSO_BASELINE_GENERATE=1 vendor/bin/paratest --processes=4
# 2. Union the worker halves and write the baseline.
vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--baseline-file=gesso-baseline.json
```
The union is deterministic — the same contract debt produces a byte-identical
file regardless of how tests were distributed across workers.
The merge fails loudly (exit 1, sidecars preserved for a retry) instead of
writing a wrong or missing baseline when:
- sidecars carry baseline data but `--baseline-file` was not given —
discarding it would silently hide the violations the workers demoted;
- `--baseline-file` was given but some sidecar carries no baseline data
(a worker ran without generation mode, or on a pre-2.2 library version) —
the union would be an incomplete baseline;
- `--baseline-file` was given but no sidecars exist at all.
Because generation demotes failures per worker, the merge step is what makes
the run trustworthy — wire both steps into the same CI job so a forgotten
merge cannot leave a green run with no baseline written. As with sequential
generation, run the full suite: per-worker slices are expected, but a
`--filter`ed parallel run cannot be detected and would bake an incomplete
baseline.
## Enforcement semantics
- A failing assertion is suppressed only when **every** one of its violations
is baselined. One new violation fails the assertion with the full,
unmodified error output (including the baselined violations — fix or
regenerate deliberately, never silently).
- The PSR-7 exchange assertion evaluates each failing side independently and
passes only when all failing sides are fully baselined.
- A missing or malformed `baseline_file` is FATAL at bootstrap. A typo'd path
must not silently disable suppression.
- A body that fails to decode as JSON is baselined as a body-category entry
with the synthetic `parse` keyword. In the Laravel and Symfony adapters it
carries no matched status/content-type context (the failure happens before
path matching); the PSR-7 adapter folds the failure into the validation
result as a `parse`-keyword issue with its matched context. Either way,
validation still continues against a placeholder body so violations
elsewhere in the request/response are not masked — and the placeholder's
own body verdicts are treated as artifacts of the decode failure: they are
neither recorded at generation time nor required to be baselined at
enforcement time.
- The `response.content_type` note — emitted when an undeclared `+json`
Content-Type fell through to the first JSON key, see
[supported-features.md](supported-features.md#body-validation) — explains
the body errors beside it rather than being a violation of its own. It is
neither recorded nor required to be baselined, so a baseline written before
the note existed keeps suppressing the same failure.
- While a baseline is active the `max_errors` cap is lifted: a truncated
error list could hide a new violation behind baselined ones. Failure output
may therefore list more errors than the configured cap.
## The end-of-run summary
Every full enforcement run prints one line to stderr:
```text
[Gesso] baseline: 42 entries, 40 hit, 2 stale (removable).
```
`hit` counts baseline entries that occurred during the run; `stale` counts
entries that never occurred — debt that has been fixed and can be removed
from the file.
## Ratcheting down
Stale entries are the ratchet-down signal. The `baseline_stale` parameter
controls how they are reported:
| Value | Behavior |
|---|---|
| `note` (default) | Stale entries are listed on stderr and in the GitHub Actions step summary |
| `fail` | Same listing, and the run exits non-zero — CI enforces that the baseline only shrinks |
| `off` | Stale entries are not evaluated |
To shrink the baseline, delete the listed entries from the file (or re-run
the generation command, which rewrites the file to exactly the current debt).
Hand-edited entries are re-normalized on load, so removing an entry is safe
without regenerating.
Stale evaluation needs a complete, clean run: an entry is only provably
gone when every assertion that could hit it actually ran. It is therefore
skipped — entries/hits are still reported, and the `fail` gate never trips —
in two cases:
- **Partial runs** (`--filter` / `--testsuite` / path arguments): the subset
cannot prove an entry no longer occurs.
- **Runs that did not complete cleanly**: any failed, errored, skipped, or
incomplete test, a skipped test suite (class-level requirements), or a
truncated run (any `--stop-on-*` flag, hook failures — detected by
comparing the planned test count against the tests that actually
finished): later assertions may never have executed, so an unhit entry
proves nothing.
## Limitations
- **Parallel runners:** enforcement (suppression) works per worker, but the
stale summary is not aggregated across workers — ratchet-down evaluation
needs a sequential full run. Generation is supported via the sidecar merge
(see [Generating under parallel runners](#generating-under-parallel-runners)).
- Suppression is per assertion, not per test: an assertion mixing baselined
and new violations fails as a whole (by design — see enforcement
semantics).
---
Source: https://studio-design.github.io/gesso/coverage-baseline.md
# Coverage baseline
The [coverage threshold gate](coverage.md#coverage-threshold-gate) answers
"is coverage above N%". That is a poor fit for "coverage must not get worse",
because a percentage has to be maintained by hand:
- setting the threshold below the current rate leaves headroom, so several
responses can go uncovered without failing;
- setting it exactly at the current rate couples the gate to the
**denominator** — documenting one new response in the spec fails an
otherwise unrelated PR, and the message only reports a percentage, not
which row caused it;
- improving coverage without also raising the threshold silently restores
the headroom, and CI stays green while the ratchet loosens.
The coverage baseline gates the **set** of uncovered responses instead. It is
the [violation baseline](baseline.md) idea applied to coverage: commit
today's gaps, fail on new ones by name, and let the ratchet show up as a diff.
## Quick start
1. Point the PHPUnit extension at a coverage baseline file:
```xml
```
2. Generate it from a full run (no `--filter`):
```bash
GESSO_BASELINE_GENERATE=1 vendor/bin/phpunit
```
```text
[Gesso] Coverage baseline written: 105 uncovered response(s) → /path/to/gesso-coverage-baseline.json
```
3. Commit the file. Every later run compares its uncovered set against it.
`GESSO_BASELINE_GENERATE` drives both baselines: if `baseline_file` is
configured too, one command regenerates both. Configuring only
`coverage_baseline_file` is fine — the violation baseline stays uninvolved.
The file is deterministic — fully sorted, no timestamps — so regenerating an
unchanged suite produces a byte-identical file. Its format is a versioned
compatibility surface; see
[versioning.md](versioning.md#whats-covered-by-semver).
## What an entry is
One entry per response the run did not validate, at the same granularity
coverage is measured at:
```json
{
"spec": "front",
"method": "GET",
"path": "/v1/pets",
"status": "500",
"content_type": "application/json"
}
```
`status` is the spec's response key (`200`, `5XX`, `default`), not an observed
HTTP status. `content_type` is `*` for responses declared without a `content`
block (204-style). Fixed HTTP methods normalize to uppercase; OpenAPI 3.2
custom `additionalOperations` methods stay case-sensitive.
**Skipped responses are baselined too.** A response matched by
`skip_response_codes`, or one whose content type has no schema engine, counts
as not covered — exactly as it does in the `min_response_coverage` numerator.
Including it means switching from the percentage gate to the baseline never
weakens the gate.
## Enforcement semantics
Every full run prints one summary line to stderr:
```text
[Gesso] coverage baseline: 105 entries, 105 uncovered response(s) in this run, 0 covered now.
```
An uncovered response that is **not** in the file fails the run and is named:
```text
[Gesso] FATAL: 2 response(s) are not covered and are not listed in the coverage baseline:
- [front] DELETE /v1/pets/{id} status=204 content-type=*
- [front] GET /v1/pets status=500 content-type=application/json
Action: cover them with a test, or accept them by regenerating the baseline with `GESSO_BASELINE_GENERATE=1 vendor/bin/phpunit`.
```
Because the gate compares sets, documenting a new response in the spec does
not fail an unrelated PR — as long as that response is covered. If it is not,
the failure names it and says so, which is the intended behavior arriving for
the intended reason.
The gate is skipped, with a one-line NOTE, when it cannot be evaluated
honestly:
- **Partial runs** (`--filter` / `--testsuite` / path arguments): a subset
leaves responses uncovered that the full suite covers.
- **Runs that did not complete cleanly** (any failed, errored, skipped, or
incomplete test, or a truncated `--stop-on-*` run): a test that never
reached its contract assertion leaves its responses uncovered, and
reporting those as regressions would bury the real failure.
A run that recorded no contract coverage at all is FATAL rather than a
vacuous pass — an empty uncovered set there means "no data", not "no gaps".
## Ratcheting down
Baseline entries that are covered now — the ratchet-down signal — are
reported per `coverage_baseline_stale`:
| Value | Behavior |
|---|---|
| `note` (default) | Entries are listed on stderr as removable |
| `fail` | Same listing, and the run exits non-zero — CI enforces that the baseline only shrinks |
| `off` | Entries that are covered now are not reported |
```text
[Gesso] NOTE: 1 coverage baseline entry(ies) are covered now and can be removed from the baseline file:
- [front] GET /v1/pets status=500 content-type=application/json
```
Delete the listed entries, or re-run the generation command — it rewrites the
file to exactly the current gaps. Either way the improvement appears in the
diff, so it cannot be forgotten the way a threshold bump can. Entries for
responses the spec no longer declares are reported the same way, since they
can never be covered again.
## Parallel runners
A paratest worker sees only its slice of the suite, so no worker can decide
whether a response is covered. Workers therefore neither generate nor enforce;
the merge step owns both halves, reading the same merged coverage state it
already renders reports from:
```bash
# Generate
GESSO_BASELINE_GENERATE=1 vendor/bin/paratest --processes=4
GESSO_BASELINE_GENERATE=1 vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--coverage-baseline-file=gesso-coverage-baseline.json
# Enforce
vendor/bin/paratest --processes=4
vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--coverage-baseline-file=gesso-coverage-baseline.json \
--coverage-baseline-stale=fail
```
The merge exits 1 when a response is newly uncovered, when no sidecars were
found, when no coverage was recorded, or when the baseline file cannot be
read; it exits 2 for usage errors (an unknown `--coverage-baseline-stale`
value, or that flag without `--coverage-baseline-file`).
On any of those failures the sidecars are **kept** instead of cleaned up, as
the violation baseline does. Every recovery re-runs the merge, not the suite:
fix a typo'd path, free disk space for the write, or accept the reported
regressions by re-running with `GESSO_BASELINE_GENERATE=1`.
A generation worker that cannot write its sidecar — and cannot drop a failure
marker either — fails the parallel run rather than letting the merge build a
baseline from the surviving workers, which would record the lost worker's
covered responses as uncovered and loosen the ratchet for good.
Unlike the sequential path, the merge has no view of whether the suite passed,
so a red parallel run can report responses of failed tests as newly uncovered.
Check the test run's own exit code first — the same caveat the threshold gate
has here.
## Using it alongside the threshold gate
They are independent and can both be configured, but the baseline is the
stronger of the two for regression protection. `min_endpoint_coverage` and
`min_sdk_exercise_coverage` still measure things the coverage baseline does
not, so keeping those while dropping `min_response_coverage` is a reasonable
setup.
---
Source: https://studio-design.github.io/gesso/coverage-gate.md
# Spec patch coverage gate
`gesso coverage:gate` fails a pull request when it changes an operation that no
test exercises. It is the OpenAPI counterpart of `diff-cover` / Codecov's patch
status: instead of "the whole spec must be 80% covered", it enforces "whatever
*this* change touched must be covered".
It needs three inputs:
| Input | Where it comes from |
|---|---|
| the base spec | `git show` for a single file, `git worktree` for a split spec (below) |
| the current spec | your working tree |
| a coverage document | [`json_output`](coverage-json-schema.md) from the test run |
```bash
git show origin/main:openapi.json > /tmp/base.json
vendor/bin/gesso coverage:gate \
--base-spec=/tmp/base.json \
--spec=openapi.json \
--coverage=build/coverage.json
```
Both documents are loaded exactly like the runtime loader loads them, so local
`$ref`s resolve **relative to their own entry document's directory**. The
single-file `git show` redirect above only works for a spec that has no local
`$ref`. For a split spec, materialise the whole base tree instead:
```bash
git worktree add /tmp/base origin/main
vendor/bin/gesso coverage:gate \
--base-spec=/tmp/base/openapi/root.yaml \
--spec=openapi/root.yaml \
--coverage=build/coverage.json
```
```text
[Gesso] 2 operations changed against the base spec:
DELETE /pets/{id}
204 (no content) UNCOVERED
PUT /pets/{id}
200 application/json covered
1 changed response is not covered by any test.
```
## What counts as a change
Both documents are resolved through the same loader the validators use, then
each operation is fingerprinted:
- **added** — the `(method, path)` is not in the base spec. Every declared
response is in scope.
- **changed** — the operation exists in both, but its resolved tree differs.
When only some responses changed, only those are in scope. When the
operation's request contract changed, every response of that operation is in
scope, because a changed request is worth re-exercising end to end.
- **removed** — listed for information only. An operation or a response that
no longer exists cannot be tested, so a removal never fails the gate. A
change that only deletes a response still reports the operation, rendering
the deleted row as `removed (not testable)`.
The request contract means the **effective** one, not just the operation
object:
- the parameters after the Path Item / operation merge — an operation-level
entry replaces the Path Item entry with the same `in` + `name`, so changing
an *already-overridden* Path Item parameter is not a change;
- the `servers` that actually apply. `servers` overrides rather than merges,
so exactly one level is ever in force: operation, else Path Item, else root.
A root `servers` change reaches every operation that inherits it, and never
reaches one that declares its own;
- the security requirement that actually applies — operation-level if
declared, otherwise the root default — together with the
`components.securitySchemes` definitions it names;
- any remaining Path Item fields the operation inherits (`summary`,
`description`, extensions).
Tightening a path-level parameter to `required`, or swapping an API key from a
header to a query parameter, therefore puts every affected operation in scope
even though no operation object changed.
The comparison is **structural, not semantic**. The gate does not classify a
change as breaking or non-breaking — that is the job of a dedicated diff tool
such as [oasdiff](https://github.com/oasdiff/oasdiff) or
[pb33f/openapi-changes](https://github.com/pb33f/openapi-changes). Because the
comparison happens after reference resolution, moving a schema into
`components` and pointing a `$ref` at it is *not* a change.
## Which operations are gated
The gate covers the methods a coverage document can actually contain: `GET`,
`POST`, `PUT`, `PATCH`, `DELETE`, `QUERY`, and OpenAPI 3.2
`additionalOperations`. `OPTIONS`, `HEAD`, and `TRACE` are skipped, because
the [coverage tracker](coverage.md) never records them — gating them would
demand coverage no report can ever show.
## What counts as covered
The gate joins each in-scope `(method, path, status, content-type)` tuple
against the coverage document at the same granularity coverage is measured at.
A tuple passes only when its `response_state` is `validated`. A `skipped`
response is rendered as `SKIPPED` and fails the gate — a skip is a deliberate
hole, and a change is exactly the moment to revisit it. A tuple missing from
the coverage document entirely counts as `UNCOVERED`.
## Options
| Flag | Description |
|---|---|
| `--base-spec=` | Spec as it looks on the base branch. Required. |
| `--spec=` | Spec as it looks on this branch. Required. |
| `--coverage=` | Coverage JSON, `schema_version` 3. Required. |
| `--spec-name=` | Key under `specs` in the coverage document. Defaults to the `--spec` filename without its extension. |
| `--format=text\|markdown` | `markdown` renders a table for `$GITHUB_STEP_SUMMARY`. Default `text`. |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Every changed operation is covered, including "nothing changed". |
| `1` | A changed operation has a response no test validated. |
| `2` | Command-line usage is invalid, or a spec / coverage file cannot be read. |
## In CI
See [CI integration](ci.md#spec-patch-coverage-gate) for a complete
`on: pull_request` workflow.
## Related
- [Coverage](coverage.md) — how coverage is measured and reported.
- [Coverage baseline](coverage-baseline.md) — ratchet the *existing* uncovered
set instead of gating on the diff. The two compose: the baseline stops
regressions from growing, the gate stops new work from arriving uncovered.
- [Coverage JSON schema](coverage-json-schema.md) — the document this command
reads.
---
Source: https://studio-design.github.io/gesso/stubs.md
# Test stub scaffolding
`gesso stubs` writes test skeletons for the responses your suite does not
exercise yet. Point it at a spec and a [coverage document](coverage-json-schema.md)
and it generates one test class per operation, one test method per uncovered
`(status, content-type)` tuple — the same granularity the
[coverage report](coverage.md) measures.
```bash
vendor/bin/gesso stubs \
--spec=openapi.json \
--coverage=build/coverage.json \
--adapter=laravel
```
```text
[Gesso] Wrote 3 files covering 7 uncovered responses to tests/Feature/Contract:
+ tests/Feature/Contract/DeletePetsPetIdTest.php
+ tests/Feature/Contract/PostPetsTest.php
+ tests/Feature/Contract/PutPetsPetIdTest.php
1 file already exists and was left untouched:
= tests/Feature/Contract/GetPetsTest.php
```
Without `--coverage` the whole spec is scaffolded. With it, a response the
document reports as `validated` is skipped; `uncovered` and `skipped` responses
both get a stub, because a skipped response is untested too.
Laravel users can run the same thing through Artisan, which takes the spec from
`gesso.default_spec` / `gesso.spec_base_path`:
```bash
php artisan gesso:stubs --coverage=build/coverage.json
```
## Options
| Option | Default | Meaning |
|---|---|---|
| `--spec=` | required | OpenAPI document (`.json` / `.yaml` / `.yml`). |
| `--coverage=` | — | Coverage JSON (`schema_version` 3). Omit to scaffold the whole spec. |
| `--spec-name=` | `--spec` filename | Key under `specs` in the coverage document, and the spec name written into the generated tests. |
| `--adapter=` | `phpunit` | `phpunit`, `laravel`, `symfony`, or `pest`. |
| `--output=` | per adapter | Where to write. `tests/Contract`, or `tests/Feature/Contract` for `laravel` and `pest`. |
| `--namespace=` | per adapter | Namespace for the generated classes. Ignored by `pest`. |
| `--base-class=` | per adapter | Test class to extend — `PHPUnit\Framework\TestCase`, or `Tests\TestCase` for `laravel`. Ignored by `pest`. |
| `--dry-run` | off | Report what would be written without writing it. |
| `--request-prefix=` | none | Prepend the application's mount path to generated Laravel, Symfony, or Pest requests, e.g. `/api`. Does not configure runtime path matching. |
Exit code `0` means stubs were written or there was nothing left to stub; `2`
means a usage error or an unreadable input.
The Artisan command takes the same options. Its `--spec` accepts a spec *name*
resolved under `gesso.spec_base_path` as well as a path.
## Applications mounted under a prefix
If your spec declares `/users` but Laravel serves `/api/users`, generate the
request with an explicit mount path:
```bash
vendor/bin/gesso stubs --spec=openapi/api.json \
--coverage=build/coverage.json --adapter=laravel --request-prefix=/api
# Equivalent, using Laravel's configured spec:
php artisan gesso:stubs --coverage=build/coverage.json --request-prefix=/api
```
The generated test calls `/api/users`; its class name, spec attribute, and
coverage identity still refer to the original operation. Path parameter values
and query strings are retained. For example, `/users/7?q=alice` becomes
`/api/users/7?q=alice`. Configure `strip_prefixes=/api` on the PHPUnit extension
separately so the runtime validator matches that request to `/users`; see
[server base paths](setup.md#server-base-paths-are-not-stripped-automatically).
The prefix is an absolute URI path, not a URL: `/api` and `/api/v1` are accepted;
hosts, query strings, fragments, and unencoded whitespace are rejected. Encode
spaces and non-ASCII characters with percent encoding. Trailing slashes are
removed before prepending, so `/api/` behaves like `/api`; an empty prefix or `/`
leaves requests unchanged. The spec's root path `/` becomes `/api/`.
Omitting the option preserves existing output. The generator does not infer a
mount from `servers` or `strip_prefixes`, and does not remove an existing prefix
from a spec path. Only supply the portion missing from the spec's paths. The
response-only `phpunit` adapter rejects a non-empty normalized prefix because
it does not send HTTP requests.
Existing files are still never overwritten, including when you change the
prefix. Edit an existing test's URL yourself or use a fresh output directory to
preview a new stub. New tests still start incomplete: review their payload,
authentication, and fixtures before removing the marker. The
[Scramble example](recipes/scramble.md#generate-the-missing-422-test) verifies this
workflow through to a passing coverage gate.
## What ends up in a stub
Each generated test is marked incomplete — `markTestIncomplete()`, or `->todo()`
for Pest — so a freshly scaffolded suite reports outstanding work instead of
failures. Removing that line is how you say the stub is finished.
Everything the spec pins down is filled in:
- **Path and query.** Path template variables are substituted, and required
query parameters are appended. Values come from the parameter's `example`,
then `schema.default` / `schema.enum`, then a type- and format-shaped
placeholder (`1` for an integer, a zero UUID for `format: uuid`, …). `TODO`
is the last resort.
- **Required headers.** `in: header` parameters marked `required` are passed on
the request.
- **Bodies.** A request body or response `example` (or the first entry of
`examples`) becomes the literal in the stub, preserving `{}` versus `[]`
even when nested. Without one you get a type-shaped placeholder (`{}` for
objects, `[]` for arrays, a scalar for a declared scalar type) under a
`// TODO` comment. This is not schema-driven generation: required properties
and other constraints still need filling in. The call shape follows the media
type: array-compatible JSON uses JSON helpers; empty objects and scalars use
raw JSON. `application/x-www-form-urlencoded` and
`multipart/form-data` are sent as request fields so the form decoders see a
field map, and anything else goes out as a raw body. Whichever shape it
takes, the declared media type is set explicitly — `Request::create()` would
otherwise default a form request to `application/x-www-form-urlencoded` (and
leave `PATCH` with no `Content-Type` at all), so a multipart operation would
never match its own `requestBody`. A multipart stub points at `UploadedFile`
for the file parts rather than hand-building a boundary.
"JSON" here means what the validator means by it — `application/json` or a
`+json` suffix. `application/vnd.acme+json` uses the same JSON dispatch rules;
`application/notjson` does not. A spec key that is a *range* (`application/*`,
`*/*`) is not something a client can put on the wire, so the stub sends a
concrete type the range covers.
A form body on an `additionalOperations` method goes out as raw urlencoded
bytes rather than request fields: `Request::create()` only moves its
parameters into the request bag for POST/PUT/PATCH/DELETE/QUERY, and routes
anything else into the query bag. `multipart/form-data` has no raw-byte form
the decoder can parse back. A multipart body on a custom method is therefore
only a dead end when it is `required` *and* no other media type is declared:
an optional body is simply omitted, and an operation that also offers
urlencoded is stubbed through that. A body no Content-Type resolves back to
at all — a `text/*` key, which no JSON type selects and no form type covers —
is the other dead end. Either way the operation is reported as not stubbable
for the Laravel, Pest, and Symfony adapters rather than emitted as a request
that would silently validate as skipped. The `phpunit` adapter is unaffected
by both — it only validates responses and never builds a request, so no
`requestBody` can cost it an operation.
A request media type is resolved the way `RequestBodyValidator` does, which
is not how responses resolve. Its JSON route has no exact-match preference,
so only the first JSON key (or `application/*`) is ever selected by a JSON
Content-Type. And forms are the one non-JSON family it still checks against a
schema, which makes `multipart/form-data` the way to reach a `multipart/*`
range — unreachable through every other route. Where several media types are
declared, the one whose schema is actually enforced wins: an operation
offering both `application/xml` and `multipart/form-data` is stubbed through
the form, because the XML would validate as Skipped whatever the body is.
- **Status codes.** A range key such as `4XX`, or `default`, is exercised as a
concrete code with a comment saying which one was picked. The code is chosen
the way the runtime resolver reads the spec — exact keys win over ranges, and
ranges over `default` — so an operation declaring both `400` and `4XX` gets a
`4XX` stub sending 401, not one that would silently validate the `400` schema.
A key no status can reach (a `4XX` alongside all 100 exact 4xx codes) is
reported rather than stubbed, because no test could ever cover it. A key that
is not a status, a range, or `default` is reported as malformed and does not
affect its operation's valid keys — run [`gesso doctor`](doctor.md) for those.
- **Specification extensions.** An `x-` key on a Responses Object is not a
response and gets no stub.
- **Content negotiation.** Each response media type gets its own `Accept`
header, so two media types declared under one status do not both resolve to
the same response. A range key (`application/*`) is exercised as a concrete
type it covers — sending the range itself would make the validator read the
body as non-JSON and skip the schema, so a violating body would pass. The
declared key still names the test and the coverage tuple it closes. Which
substitute works depends on the key: the JSON resolver takes an exact match
first and otherwise the first JSON entry, so a range declared *alongside* a
literal JSON key is unreachable through a JSON Content-Type. A non-JSON one
reaches it — the general matcher matches `/*` in its second pass — but
only pays off when the range declares no `schema`, since a non-JSON type that
lands on one is skipped as a contract this engine cannot evaluate. So a
schema-less range is stubbed with a concrete non-JSON type, and a range
carrying a schema next to a JSON sibling is reported rather than stubbed.
The substitute is searched for rather than guessed, on both halves of the
media type: a spec is free to declare the invented type the stub would have
reached for, and `*/*` is only matched after every `/*` sibling, so a
document ranging over `application/*` and `text/*` gets something like
`image/…` for its full wildcard.
- **Bodies only a skip can reach.** OpenAPI 3.2 `itemSchema` streaming cannot
be checked from a buffered body, and a non-JSON media type carrying a
`schema` is a contract this JSON Schema engine does not evaluate. Both still
get a stub — it exercises the endpoint and moves the tuple off `uncovered` —
but with a `TODO` saying the assertion can only ever pass as Skipped, because
the generated `isValid()` is satisfied by a skip and the coverage document
will keep reporting the tuple as skipped rather than validated. This applies
to the request body as well as the response, except for the `phpunit` adapter,
which never sends a body.
- **Responses without `content`.** These become a single "no content" test, the
same tuple the coverage tracker records for a 204.
```php
#[OpenApiSpec('petstore')]
final class PostPetsTest extends TestCase
{
use ValidatesOpenApiSchema;
public function test_post_pets_201_application_json(): void
{
$this->markTestIncomplete('Exercise POST /pets returns 201 application/json.');
// TODO: adjust the payload your application expects.
$payload = [
'name' => 'Fido',
];
$response = $this->postJson(
'/pets',
$payload,
[
'Accept' => 'application/json',
],
);
$response->assertStatus(201);
$this->assertResponseMatchesOpenApiSchema($response);
}
}
```
Each adapter follows its own [quickstart](quickstarts/laravel.md) idiom, so
generated code reads like the documented usage rather than a dialect of its own.
A request body that Laravel's JSON helpers cannot carry — an empty object, a scalar, or a
non-JSON media type — is sent through `call()` with an explicit `Content-Type`
instead of `postJson()`.
Pest stubs default into `tests/Feature/Contract` because they generate Laravel
HTTP calls: they need the
`uses(TestCase::class, ValidatesOpenApiSchema::class)->in('Feature')` binding
from [the Pest guide](pest-plugin.md) to have a harness once `->todo()` comes
off. Point `--output` elsewhere only if your `uses(...)` reaches there.
## Re-running it
The command never overwrites a file. Once you have edited
`PostPetsTest.php`, a later run reports it as untouched and writes only the
operations that have appeared since. Output is deterministic — operations are
ordered by `METHOD path`, responses by status then content type — so two runs on
the same inputs produce byte-identical files.
That makes the loop straightforward: run your suite, regenerate, fill in the
next batch.
```bash
vendor/bin/phpunit # writes build/coverage.json
vendor/bin/gesso stubs --spec=openapi.json --coverage=build/coverage.json
```
Class names come from the method and path — `GET /pets/{petId}` becomes
`GetPetsPetIdTest` — not from `operationId`, so a document that reuses an
`operationId` cannot collide two operations into one file. Where two paths do
normalise to the same name (`/foo-bar` and `/foo/bar`), each gets a suffix
derived from its own endpoint, so neither is dropped and neither name shifts
when an unrelated operation joins the spec. Collisions are resolved against
every operation the spec declares, not just the uncovered ones, so a name stays
put once the other side of a collision goes green.
`--spec` is loaded through the runtime loader, which resolves a *name* and
searches `.json` before `.yaml` before `.yml`. Passing `--spec=openapi.yaml`
next to an `openapi.json` fails rather than silently stubbing the JSON —
the same shadowing check [`gesso doctor`](doctor.md) makes.
## Scope
Only the methods the coverage tracker records are stubbed: `GET`, `POST`, `PUT`,
`PATCH`, `DELETE`, `QUERY`, and OpenAPI 3.2 `additionalOperations`. `OPTIONS`,
`HEAD`, and `TRACE` never appear in a coverage document, so a stub for one could
never turn a tuple green.
Generated tests are yours to edit; they are ordinary test files with no runtime
dependency on the generator.
---
Source: https://studio-design.github.io/gesso/psr7.md
# PSR-7 request and response validation
Use the PSR-7 adapter when your application or HTTP client already exposes
`RequestInterface`, `ServerRequestInterface`, and `ResponseInterface` messages.
It validates both sides of an exchange through the same core validators and
records the same response-level coverage as the Laravel and Symfony adapters.
No concrete PSR-7 implementation is required by this package. The examples use
Guzzle PSR-7, but Nyholm PSR-7, Laminas Diactoros, Slim messages, and other
implementations of `psr/http-message` use the same API.
## Result API
Configure the spec loader once, then construct an adapter for a spec:
```php
use Studio\Gesso\Psr7\OpenApiPsr7Validator;
use Studio\Gesso\Spec\OpenApiSpecLoader;
OpenApiSpecLoader::configure(__DIR__ . '/openapi', ['/api']);
$validator = new OpenApiPsr7Validator('front');
$result = $validator->validateExchange($request, $response);
if (!$result->isValid()) {
throw new RuntimeException($result->errorMessage());
}
```
`validateExchange()` validates the request first and forwards the response
status to the request validator, preserving the documented-4xx downgrade used
by the framework adapters. The returned `OpenApiPsr7ValidationResult` exposes
`requestResult()` and `responseResult()` when the two outcomes need to be
handled separately.
The individual entry points are:
```php
$requestResult = $validator->validateRequest($request, $response->getStatusCode());
// Resolve method and path from a RequestInterface.
$responseResult = $validator->validateResponse($request, $response);
// Or provide the operation address when only a response is available.
$responseResult = $validator->validateResponseForOperation(
method: 'POST',
requestPath: '/v1/pets',
response: $response,
);
```
A `ServerRequestInterface` contributes its parsed query and cookie parameters.
For a client-side `RequestInterface`, the adapter parses the URI query and
`Cookie` header. Method spelling is preserved so OpenAPI 3.2 custom
`additionalOperations` remain case-sensitive.
## PHPUnit assertions
Mix the PSR-7 assertion trait into a PHPUnit test and select the spec with an
attribute or the `openApiSpec()` hook:
```php
use PHPUnit\Framework\TestCase;
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Psr7\OpenApiAssertions;
#[OpenApiSpec('front')]
final class CreatePetTest extends TestCase
{
use OpenApiAssertions;
public function test_create_pet(): void
{
[$request, $response] = $this->callApplication();
$this->assertPsr7ExchangeMatchesOpenApiSchema($request, $response);
}
}
```
Without an attribute or override, `openApiSpec()` returns `spec.default` from
[`gesso.php`](configuration.md) (or the `default_spec` extension parameter).
Likewise `openApiMaxErrors()`, `openApiSkipResponseCodes()`, and
`openApiSkipRequestValidationResponseCodes()` default to the shared
`validation.*` settings; override a hook to pin a per-suite value.
Request-only, response-only, and explicit-operation assertions are also
available:
```php
$this->assertPsr7RequestMatchesOpenApiSchema($request, $response->getStatusCode());
$this->assertPsr7ResponseMatchesOpenApiSchema($request, $response);
$this->assertPsr7ResponseForOperationMatchesOpenApiSchema('POST', '/v1/pets', $response);
```
Assertion failures end with a redacted `Reproduce: curl …` line (for the
exchange assertion, one line for the whole exchange, with each error prefixed
by its `[request]` / `[response]` side). In the JSON failure output mode the
exchange failure instead emits one labelled JSON document per failing side.
See [reproduction commands](setup.md#reproduction-commands-in-failure-output)
and [validation-json-schema.md](validation-json-schema.md).
## PSR-15 test integration
Keep contract validation in tests rather than placing validation middleware on
the production request path. A PSR-15 handler test can validate the exchange
immediately after invoking the handler:
```php
final class ContractTest extends TestCase
{
public function test_handler_contract(): void
{
$request = $this->serverRequestFactory->createServerRequest('GET', '/v1/pets');
$response = $this->handler->handle($request);
$result = $this->openApi->validateExchange($request, $response);
$this->assertTrue($result->isValid(), $result->errorMessage());
}
}
```
This works at the outer handler boundary or in a test-only middleware harness;
the adapter does not require `psr/http-server-middleware` and adds no runtime
middleware to the application.
## Body streams
The adapter preserves a seekable stream's current cursor: it remembers the
position, reads from the beginning, and restores the original position before
returning. Empty, scalar, object/array, and literal JSON `null` bodies remain
distinct.
A non-seekable JSON stream cannot be read and then restored through the
[PSR-7 `StreamInterface`](https://github.com/php-fig/http-message/blob/2.0/src/StreamInterface.php).
The adapter therefore returns a failure without reading it. Buffer or decorate
the body with a seekable/caching stream before validation when the application
uses a one-pass stream. This also applies when a form request needs raw bytes
because no parsed fields are available. A read or JSON parse failure produces
only a parse issue for that body, not a second empty-body or schema error.
Other parameter/header/security violations remain visible, including when the
request produced a documented 4xx response.
Opaque (non-JSON, non-form) request presence is inspected only when the resolved
contract requires it. Parsed fields/files or a known size need no read; an
unknown-size seekable stream needs only `read(1)`, with its cursor restored.
Optional, undeclared, and unmatched opaque bodies are not inspected. The core
still checks the declared content type and reports unsupported schema
enforcement as skipped.
See the [runnable Guzzle example](https://github.com/studio-design/gesso/tree/main/examples/psr7) for a complete
project.
---
Source: https://studio-design.github.io/gesso/laravel-route-parity.md
# Laravel route parity
`gesso:routes` compares Laravel's registered routes with the operations in
one or more OpenAPI specs. It catches drift before runtime coverage can: a
documented operation may have no application route, or an application route
may never have been documented.
Route parity complements contract coverage. Parity checks that a route and an
operation exist; runtime coverage still proves that tests exercised the
operation and validated its responses.
## Configuration
Publish the Laravel config if you have not already:
```bash
php artisan vendor:publish --tag=gesso
```
Configure the spec directory, default spec, and the same prefixes used by the
PHPUnit extension:
```php
return [
'default_spec' => 'front',
'spec_base_path' => base_path('openapi/bundled'),
'strip_prefixes' => ['/api'],
'route_parity' => [
'external_operation_ids' => ['forms.*'],
'external_openapi_paths' => ['/v2/proxy/*'],
],
];
```
`spec_base_path` is the directory searched for `front.json`, `front.yaml`, or
`front.yml`. Keep `strip_prefixes` aligned with the PHPUnit extension — or move both to
the root [`gesso.php`](configuration.md) so nothing needs aligning. The
command applies the runtime matcher's first-match prefix and trailing-slash
rules, so the static result matches request/response validation.
## Usage
Use the configured `default_spec`:
```bash
php artisan gesso:routes
```
Select multiple specs and narrow the Laravel route collection:
```bash
php artisan gesso:routes \
--spec=front \
--spec=admin \
--prefix=api/v2 \
--middleware=api \
--domain=api.example.com \
--exclude-route='internal.*' \
--exclude-operation='forms.*' \
--exclude-openapi-path='/v2/proxy/*'
```
Filters are combined with AND semantics:
- `--prefix` matches a complete Laravel URI prefix segment.
- Repeat `--middleware` to require every listed middleware name.
- Repeat `--domain` to allow any listed exact route domain.
- Repeat `--exclude-route` to exclude named routes; `*` wildcards are
supported. Unnamed routes are not excluded by this option.
Documented-side exclusions classify operations implemented by another service
in a gateway topology. Repeat `--exclude-operation` to match `operationId`, or
`--exclude-openapi-path` to match the OpenAPI path template. Both support `*`
wildcards and are merged with `route_parity.external_operation_ids` and
`route_parity.external_openapi_paths` from the Laravel config. An exclusion is
applied only when the operation has no matching Laravel route: implemented
operations remain in `matched`. External operations are reported separately
instead of being silently removed.
Laravel parameter names do not need to equal OpenAPI parameter names:
`/pets/{pet}` matches `/pets/{petId}`. A trailing optional Laravel parameter
is compared in both forms, so `/users/{user?}` can implement both `/users` and
`/users/{userId}`. Laravel's implicit `HEAD` on a `GET` route is ignored when
the spec omits `head`, but it implements and matches an explicitly documented
OpenAPI `head` operation on the same path.
Fallback routes are reported as ambiguous because they cannot prove that one
specific OpenAPI path is implemented. A custom HTTP method is supported when
the selected OpenAPI 3.2 spec declares the same, case-sensitive key under
`additionalOperations`; otherwise it is reported as unsupported.
## CI exit codes
By default, discovered differences are reported but the command exits `0`.
Enable either gate independently:
```bash
php artisan gesso:routes --fail-on-undocumented
php artisan gesso:routes --fail-on-unimplemented
```
- `--fail-on-undocumented` exits `1` for registered-but-undocumented routes or
unsupported route methods.
- `--fail-on-unimplemented` exits `1` for documented-but-not-registered
operations, excluding those classified under `external_operations`.
- Invalid command options exit `2`; load/configuration failures exit `1`.
## JSON output
Use stable machine-readable output in CI:
```bash
php artisan gesso:routes --format=json
```
For large applications, capture the formatted JSON as a CI artifact instead
of relying only on the job log:
```yaml
- name: Compare Laravel routes with OpenAPI
run: php artisan gesso:routes --format=json > route-parity.json
- name: Upload route parity report
uses: actions/upload-artifact@v4
with:
name: openapi-route-parity
path: route-parity.json
```
The top-level `schema_version` is currently `2`. Version 2 adds the
`external_operations` result and summary count. The payload contains the
selected `specs`, a `summary`, and these result arrays:
- `matched`
- `documented_but_not_registered`
- `external_operations`
- `registered_but_undocumented`
- `ambiguous`
- `unsupported`
Paths and route names are emitted, but absolute filesystem paths and spec
contents are not.
---
Source: https://studio-design.github.io/gesso/fuzzing.md
# Schema-driven fuzzing
The `ExploresOpenApiEndpoint` trait ships for Laravel only. It generates
deterministic request inputs for one operation or a filtered whole spec. Symfony,
Pest, and plain PHPUnit users call the framework-agnostic
[`OpenApiSpecExplorer::explore()`](#framework-agnostic-exploration) instead. The
workflow is inspired by
[Schemathesis][schemathesis], but the supported strategy matrix below is the
contract; this package does not claim feature parity.
```php
use Tests\TestCase;
use Studio\Gesso\Fuzz\ExploredCase;
use Studio\Gesso\Laravel\ExploresOpenApiEndpoint;
use Studio\Gesso\Laravel\ValidatesOpenApiSchema;
use Studio\Gesso\Attribute\OpenApiSpec;
#[OpenApiSpec('front')]
class CreatePetTest extends TestCase
{
use ExploresOpenApiEndpoint;
use ValidatesOpenApiSchema;
public function test_create_pet_contract(): void
{
$this->exploreEndpoint('POST', '/v1/pets', cases: 50, seed: 1)
->each(fn (ExploredCase $case) => $this->call(
'POST',
$case->uri('/api'),
cookies: $case->cookies,
server: $this->transformHeadersToServerVars([
'Accept' => 'application/json', 'Content-Type' => 'application/json', ...$case->headers,
]),
content: $case->bodyAsJson(),
)->assertSuccessful());
}
}
```
What you get per case (`Studio\Gesso\Fuzz\ExploredCase`):
| Property | Description |
|--------|-------------|
| `body` | Generated JSON body (or `null` when the operation has no `application/json` requestBody) |
| `query` | name → value for every `in: query` parameter |
| `headers` | name → value for every `in: header` parameter (excludes the OpenAPI-reserved `Accept`/`Content-Type`/`Authorization`) |
| `pathParams` | name → value for every `{placeholder}` segment |
| `cookies` | name → value; empty unless a check populated it (cookie generation is not implemented). A dispatcher must forward these to its client's cookie bag — a `Cookie:` header alone never reaches `$request->cookie(...)` in Laravel or Symfony test clients |
| `method`, `matchedPath` | The resolved spec template (`/v1/pets/{petId}`) and its method |
| `kind`, `targetKeyword`, `targetPointer` | Valid/invalid classification and the single constraint targeted by a negative case |
| `expectedStatusClasses` | Explicit response classes supplied for a negative case (for example `[4]`) |
| `seed`, `caseIndex` | Stable replay identity |
The collection is `Countable` and `IteratorAggregate`, so `foreach ($cases as $case)` works too if you prefer it over the fluent `each()` helper.
Use `$case->bodyAsJson()` to encode the original value and send raw JSON, as above.
`bodyAsArray()` is a lossy compatibility helper for array-only consumers:
it recursively turns `{}` into `[]`, including nested objects. Feeding it to
Laravel's `postJson()` or Symfony's `jsonRequest()` can turn a generated valid
object case into an invalid array. Do not use `JSON_FORCE_OBJECT`, which would
also change genuine JSON arrays. `bodyAsJson()` encodes null as literal JSON
`null`; when an operation declares no body or a check omits a required body,
omit the raw content instead. Encoding failures throw `JsonException`.
`uri($prefix)` substitutes and URL-encodes path
parameters, prepends an optional application prefix, and appends the generated
query string.
## Explore response payloads against a generated SDK
`OpenApiResponseExplorer` exercises the other side of the contract: whether a
generated SDK can decode every supported branch of one response schema and
encode the values without losing data. Unlike request exploration's fixed case
count and rotation, the response explorer derives enough cases to cover every
branch of every reachable choice point, then appends any requested
`extraCases`.
```php
use Studio\Gesso\Fuzz\GeneratedResponseCase;
use Studio\Gesso\Fuzz\OpenApiResponseExplorer;
OpenApiResponseExplorer::explore(
'front',
'POST',
'/oauth/introspect',
200,
seed: 1,
)->each(function (GeneratedResponseCase $case): void {
$model = ObjectSerializer::deserialize($case->bodyAsObject(), IntrospectResponse::class);
$case->assertRoundTrip(ObjectSerializer::sanitizeForSerialization($model));
});
```
Laravel tests using `ExploresOpenApiEndpoint` can call
`$this->exploreResponseSchema(...)` with the same arguments except the spec
name. If `seed` is omitted, response exploration uses the replayable default
seed `0`. See the [SDK round-trip guide](sdk-roundtrip.md) for the complete case
contract, fidelity rules, deterministic replay, and unsupported response
outcomes.
To replace one-test-per-response maintenance with a loud spec-wide gate,
register explicit SDK mappings on `OpenApiResponseExplorer::exploreSpec()`:
```php
OpenApiResponseExplorer::exploreSpec('front', seed: 1)
->includeTags(['public'])
->mapResponse(
'introspect',
200,
static fn (GeneratedResponseCase $case): mixed => sdk_decode($case->bodyAsObject()),
static fn (mixed $model): mixed => sdk_encode($model),
)
->failOnUnmapped()
->assertRoundTrips();
```
The plan uses the whole-spec tag/method/path/operation/deprecated filters and
stable per-operation crc32 seeds. Exact, range, and `default` status mappings
are supported; one mapping exercises every JSON media type for that response.
The summary distinguishes explicit skips from decoder and round-trip failures,
while `failOnUnmapped()` makes newly added unmapped schemas fail CI. Laravel
tests call `$this->exploreResponseSpec()` for the same builder. See the
[SDK round-trip guide](sdk-roundtrip.md#exercise-every-mapped-response-in-a-spec)
for callback and reporting details.
## Explore a whole spec
`exploreSpec()` enumerates the Path Item operations defined by OpenAPI,
including 3.2 `additionalOperations`, then generates and dispatches cases for
every selected method supported by the explorer. It uses the same Laravel spec
resolution as response validation (`#[OpenApiSpec]`, `openApiSpec()`, then
`default_spec`). The two traits are designed to be composed:
```php
use Studio\Gesso\Fuzz\ExploredCase;
use Studio\Gesso\Fuzz\ExploredOperation;
use Studio\Gesso\HttpMethod;
use Studio\Gesso\Laravel\ExploresOpenApiEndpoint;
use Studio\Gesso\Laravel\ValidatesOpenApiSchema;
class ApiContractTest extends TestCase
{
use ExploresOpenApiEndpoint;
use ValidatesOpenApiSchema;
public function test_public_contract(): void
{
$summary = $this->exploreSpec(casesPerOperation: 20, seed: 20260711)
->includeTags(['public'])
->excludeOperations(['admin.users.destroy'])
->authenticateUsing(fn (ExploredOperation $operation) => $this->actingAs($this->userFor($operation)))
->dispatchUsing(fn (ExploredCase $case) => match ($case->method) {
HttpMethod::GET => $this->get($case->uri('/api'), $case->headers),
HttpMethod::POST => $this->call(
'POST',
$case->uri('/api'),
cookies: $case->cookies,
server: $this->transformHeadersToServerVars([
'Accept' => 'application/json', 'Content-Type' => 'application/json', ...$case->headers,
]),
content: $case->bodyAsJson(),
),
default => throw new LogicException('Add the method to the test dispatcher.'),
})
->assertResponses(); // ValidatesOpenApiSchema auto_assert validates each response
self::assertFalse($summary->hasSkips(), $summary->skips[0]->reason ?? '');
}
}
```
### Framework-agnostic exploration
`ExploresOpenApiEndpoint` is Laravel-only; there is no Symfony equivalent.
Symfony, Pest, and plain PHPUnit users call `OpenApiSpecExplorer::explore()`
directly. It returns the same filter/hook builder, so everything below applies
unchanged:
```php
use Studio\Gesso\Fuzz\ExploredCase;
use Studio\Gesso\Fuzz\OpenApiSpecExplorer;
$summary = OpenApiSpecExplorer::explore('petstore', casesPerOperation: 20, seed: 7)
->dispatchUsing(fn (ExploredCase $case) => $this->sendRequest($case))
->assertResponseUsing(fn (mixed $exchange) => $this->assertExchangeIsValid($exchange));
```
`dispatchUsing()` returns whatever your client produces and
`assertResponseUsing()` validates it. The runnable
[`examples/psr7`](https://github.com/studio-design/gesso/tree/main/examples/psr7)
suite demonstrates this with `OpenApiPsr7Validator` assertions.
### Filters and hooks
- `includeTags()` / `excludeTags()` match when any operation tag overlaps.
- `includeMethods()` / `excludeMethods()`, `includePaths()` /
`excludePaths()`, and `includeOperations()` / `excludeOperations()` use exact
values. Fixed HTTP methods are canonicalized; OpenAPI 3.2 custom method
spelling stays case-sensitive.
- Deprecated operations are excluded by default. Call `includeDeprecated()`
to opt in.
- `setUpUsing()` and `tearDownUsing()` run once per operation;
`authenticateUsing()` runs after setup and before its cases.
- `mutateCasesUsing()` runs per generated case and must return an
`ExploredCase`. Its `withBody()`, `withQuery()`, `withHeaders()`,
`withCookies()`, and `withPathParams()` helpers support credentials, stateful
IDs, and other request-specific changes without mutating shared state.
Operations that are declared but cannot be generated are returned in
`SpecExplorationSummary::$skips` with their reason. This includes schema-less
required inputs and methods outside `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and
`QUERY`. Malformed `paths`, Path Item, `additionalOperations`, and Operation
Object nodes are spec errors, not unsupported operations: a structural preflight
fails before any request is dispatched. Filters intentionally remove operations
and therefore do not create skip entries. A filter set matching nothing fails
loudly instead of producing a test that asserted nothing.
### Replay and parallel runs
The global seed is deterministic. Each operation receives a stable derived
seed based on `(spec, method, path, global seed)`, so adding or reordering a
different operation does not change existing cases. A dispatch, mutation, or
assertion failure prints the spec, operation ID, method/path, both seeds, case
index, and a minimal `OpenApiEndpointExplorer::explore(...)` replay expression.
Each case also exposes `replayToken()`, `replaySnippet($specName)`, and
`curlSnippet($baseUrl)`. The token identifies generation inputs; the PHP and
curl output include the concrete generated request. `curlSnippet()` redacts
sensitive header values (`Authorization`, `Cookie`, API-key/token/secret-style
names) as `` so the command is safe to paste into CI logs; pass
`redactSensitiveHeaders: false` to keep the raw values.
Summaries are local immutable values; the explorer adds no process-global
aggregation. Run one plan per parallel worker partition and use the existing
coverage sidecar/merge workflow to aggregate validated response rows.
### Safety for mutating operations
Whole-spec exploration can execute `POST`, `PUT`, `PATCH`, and `DELETE`. Run it
only against an isolated test database or disposable environment, wrap each
case/operation in your framework's transaction reset, and start with
`includeMethods(['GET', 'QUERY'])` when evaluating an unfamiliar spec. Use
`includeOperations()` for a deliberate allowlist when an endpoint triggers
external effects that a database rollback cannot undo.
## Generation behaviour
Every purportedly valid value is checked against the converted JSON Schema
before dispatch. A generator bug therefore fails locally with an `Internal
fuzz generator defect` diagnostic instead of sending invalid data to the API.
| Strategy | Valid generation | Targeted invalid mutation |
|---|---|---|
| Scalars | `type`, `const`, `enum`, nullable branches | wrong type, const/enum miss |
| Strings | min/max length, common regex patterns (including anchored character classes with fixed or `+` quantifiers, FQDN labels with a fixed domain suffix, and phone-number alternations), Unicode code points, Faker-backed email/UUID/date/time/URI/host/IP formats | below/above length, pattern miss, invalid recognized format |
| Numbers | inclusive/exclusive bounds and `multipleOf`, including OAS 3.0 boolean-exclusive lowering | outside/equal-exclusive bound, non-multiple |
| Arrays | `items`, `prefixItems`, min/max items, `uniqueItems` | too few/many or duplicate items |
| Objects | properties, required, min/max properties, schema-valued/default additional properties | missing required, extra forbidden, too few/many properties, nested property constraint |
| Composition | Request cases rotate `oneOf`/`anyOf` and conditional branches. Response payload exploration guarantees at least one case for every branch of every reachable composition, nullable, and optional-property choice point. Both merge `allOf`/range assertions and preserve lowered discriminator branches. | deterministic composition miss where one can be isolated |
Arbitrary regex synthesis, recursive schemas, `contains`,
`patternProperties`, `dependentSchemas`, and `unevaluated*` generation are not
currently strategies. Those keywords remain validator features; an operation
whose valid value cannot be synthesized fails locally with a supported-subset
diagnostic or is an explicit whole-spec skip carrying that reason.
Branch-complete response enumeration is bounded to 32 nested property/item
levels, 256 collected choice points, and 10,000 visited schema nodes across all
branch contexts. Exceeding any limit fails locally with a supported-subset
diagnostic; Gesso never returns partial branch coverage.
- Request cases alternate optional object properties between included and omitted. Response payload exploration pins both presence branches for every reachable optional property.
- Required keys are always emitted.
- Path resolution accepts both the spec template form (`/v1/pets/{petId}`) and concrete URIs that match it (`/api/v1/pets/123` with `strip_prefixes=/api`). Captured URI values are intentionally discarded — `pathParams` is always regenerated from the operation spec for consistency.
## `seed` and determinism
When [`fakerphp/faker`][faker] is installed, generation uses Faker's
locale-aware primitives and is deterministic for a given `seed:`. Without it,
ordinary strings and scalar boundaries use deterministic counter-based values.
Supported character-class patterns sample multiple class members after the
first boundary case, so increasing `cases:` also increases their input variety.
Recognized formats such as email and UUID cannot be synthesized reliably by
that fallback: the existing one-shot warning is followed by the valid-case
self-check, so the operation fails locally rather than dispatching a value that
does not satisfy its format.
```bash
# Required when explored schemas use Faker-backed formats
composer require --dev fakerphp/faker
```
## Negative cases and reduction
Negative exploration requires the expected response class. There is no
implicit "anything except 5xx" fallback:
```php
$this->exploreInvalidEndpoint(
'POST',
'/v1/pets',
expectedStatusClasses: [4],
cases: 20,
seed: 7,
)->each(function (ExploredCase $case): void {
$response = $this->call(
'POST',
$case->uri('/api'),
cookies: $case->cookies,
server: $this->transformHeadersToServerVars([
'Accept' => 'application/json', 'Content-Type' => 'application/json', ...$case->headers,
]),
content: $case->bodyAsJson(),
);
self::assertContains(intdiv($response->getStatusCode(), 100), $case->expectedStatusClasses);
});
```
For a whole spec, add `->negativeCases([4])` before `dispatchUsing()` and
inspect the same metadata in `assertResponseUsing()`. Each generated invalid
case is self-checked to ensure it actually fails the complete schema.
`FailureReducer::reduce($case, $classify)` deterministically removes body
members only while the callback returns the original non-empty classification.
Use a stable value such as `status:500` or an exception class. Reduction is
deliberately classification-preserving; it never equates every failure.
## Named contract checks
Schema validation cannot see protocol-level contract holes — an ignored
`security` requirement, a `required: true` header nobody checks, an
undocumented method that answers 200. The opt-in `ignored_auth`,
`missing_required_header`, and `unsupported_method` checks probe those
directly and live in `Studio\Gesso\Contract\`. See
[named contract checks](contract-checks.md).
## Remaining gaps
- Arbitrary ECMA-262 regex synthesis and recursive/reference-cycle generation.
- Cookie and `parameters[].content` fuzz generation.
- Structural shrinking inside nested arrays/objects; current reduction removes
top-level body members.
- Measurement-based feature parity with Schemathesis.
[schemathesis]: https://github.com/schemathesis/schemathesis
[faker]: https://github.com/FakerPHP/Faker
---
Source: https://studio-design.github.io/gesso/contract-checks.md
# Named contract checks
Schema validation cannot see protocol-level contract holes. Named checks probe
them directly, mirroring [Schemathesis check names][schemathesis-checks] where
behavior matches. The pipeline is opt-in per check, deterministic under
`seed`, and collects every result before you assert — one run reports all
holes, not just the first.
The checks live in `Studio\Gesso\Contract\`; they consume generated cases from
`Studio\Gesso\Fuzz\` (`ExploredCase`, `ExploredOperation`), never the
reverse. The `Studio\Gesso\Fuzz\` spellings still resolve through a
deprecated alias until v3.0 — see [Deprecations in `UPGRADING.md`](https://github.com/studio-design/gesso/blob/main/UPGRADING.md#deprecations).
```php
use Studio\Gesso\Contract\ContractCheck;
use Studio\Gesso\Contract\OpenApiContractChecks;
use Studio\Gesso\Fuzz\ExploredCase; // still Fuzz\: a generated case
$summary = OpenApiContractChecks::run('petstore', seed: 7)
->checks([
ContractCheck::IgnoredAuth,
ContractCheck::MissingRequiredHeader,
ContractCheck::UnsupportedMethod,
])
->dispatchUsing(fn (ExploredCase $case): int => $this->send($case)->getStatusCode())
->report();
self::assertSame([], $summary->failures, $summary->describeFailures());
```
| Check | Probe | Default pass statuses |
|---|---|---|
| `ignored_auth` | per operation with an effective `security` requirement: the valid request with no credentials, then again with credentials the API cannot have issued | `401`, `403` |
| `missing_required_header` | per `required: true` header parameter: the valid request with that one header omitted, gated behind a 2xx control request; state-changing methods need `dispatchIsolatedUsing()` | any `4xx` |
| `unsupported_method` | one deterministically chosen undocumented method per documented path | `405` |
`dispatchUsing()` may return an `int` status, a PSR-7 response, or any object
exposing `getStatusCode(): int` (Symfony `Response`, Laravel `TestResponse`).
The same `includeTags()`/`excludePaths()`/… filters and
`setUpUsing()`/`authenticateUsing()`/`tearDownUsing()` hooks as whole-spec
[exploration](fuzzing.md) apply; a path is probed when at least one of its documented
operations passes the filters. `ignored_auth` is the one exception to
`authenticateUsing()`: its probes deliberately bypass the hook, because
handing them credentials is exactly what the check exists to prevent.
Override an expectation per check with
`expectedStatuses(ContractCheck::UnsupportedMethod, [405, 404])` for frameworks
that answer unknown methods with 404, or
`expectedStatusClasses(ContractCheck::IgnoredAuth, [4])` to accept any client
error. Either call replaces the check's whole default expectation, so
`expectedStatuses(ContractCheck::MissingRequiredHeader, [400])` means exactly
400 — the default `4xx` class no longer widens it.
### `ignored_auth`
An operation is probed when its effective `security` — operation-level if
declared, root-level otherwise — actually demands credentials. `security: []`
and a requirement list containing an empty `{}` entry both document
unauthenticated access, so they are skipped rather than reported. Anything else
malformed is a hard error instead — reading it as "no authentication required",
or probing a declaration the runtime validator rejects, would turn a broken spec
into a green run. That covers the outer node (`security: "not-a-list"`, a scalar
requirement entry) and the inside of each entry: non-list scopes, non-string
scope items, undefined scheme names, and malformed scheme definitions. The
detection rules are the runtime validator's own
(`SecurityValidator::inspectRequirementPair()`), shared rather than
re-derived, so the two cannot disagree about which specs are broken.
The invalid-credential probe writes a placeholder into every credential
location the operation declares (all of them, so an AND-style requirement is
genuinely exercised): `Authorization: Bearer …` for `http` + `bearer`, and the
declared header / query / cookie name for `apiKey`. Those are the schemes
[request validation can locate](supported-features.md#security-schemes);
operations secured
solely by `oauth2`, `openIdConnect`, `mutualTLS`, or non-bearer `http` schemes
get the no-credential probe plus a skip for the other one, rather than a
fabricated credential in a guessed location.
The no-credential probe also strips any `apiKey` header, query, or cookie value
the generated valid case happened to carry, so it is genuinely credential-free.
It cannot, however, see credentials your own `dispatchUsing()` closure adds —
send the case as-is there.
**Cookie credentials require dispatcher cooperation.** `apiKey` + `in: cookie`
values land in `$case->cookies`, not in a `Cookie:` request header, because
Laravel's and Symfony's test clients build their cookie bag from a separate
`SymfonyRequest::create()` argument — a header alone leaves
`$request->cookie(...)` empty. A dispatcher that drops `$case->cookies` makes
the invalid-credential probe identical to the no-credential one, and an API
that accepts any cookie value would pass. Forward them:
```php
->dispatchUsing(fn (ExploredCase $case): int => $this->call(
$case->method->value,
$case->uri(),
[],
$case->cookies, // <- not optional for cookie-secured operations
[],
$this->transformHeadersToServerVars($case->headers),
$case->body !== null ? $case->bodyAsJson() : null,
)->getStatusCode())
```
### `missing_required_header`
One probe per `required: true` `in: header` parameter, each omitting exactly
that header so the failure names the one that was not enforced.
`Accept`/`Content-Type`/`Authorization` parameter declarations are excluded:
per OAS 3.x §4.7.12.1 they are ignored, and content negotiation and security
schemes own them. Operations that declare no required header are skipped with
a reason.
The default expectation is the `4xx` class rather than a status list, because
frameworks answer a missing required header with 400, 406, 422, or a
scheme-specific 401/403 — pinning one of them would report framework choice as
contract drift.
Accepting a family that wide is only sound with a control: each operation first
dispatches the **unmutated** valid case, and the omission probes run only when
that control answers a 2xx. Without it, an operation that never enforces the
header would score green because the request was rejected for an unrelated
reason — no credentials configured, a missing fixture, a 404. A 3xx does not
qualify either: Laravel answers an unauthenticated non-JSON request with a 302
to the login page, and comparing the omission probes against that redirect
proves nothing. When the control does not return 2xx, the operation is skipped
with its status in the reason, so use `setUpUsing()` / `authenticateUsing()` to
make the valid request succeed. The control counts toward
`$summary->dispatchedProbes`.
**State-changing methods are skipped by default.** On `POST` / `PUT` / `PATCH`
/ `DELETE`, the control request's own effect can answer the probes that follow
it — a duplicate create is a 409, an already-deleted resource is a 404, and
both sit inside the accepted 4xx class — so the status cannot distinguish them
from real header enforcement.
Nothing observable from inside the plan tells an isolated dispatcher from a
leaky one. Repeating the control does not: an idempotent create answers 201
twice and still leaves the row behind, so the next probe collides with it.
General hooks do not either — `setUpUsing()` / `tearDownUsing()` may exist for
authentication, logging, or fixtures, and a no-op one proves nothing. So the
decision is the caller's, stated on the dispatcher that has to honour it:
```php
->dispatchIsolatedUsing(fn (ExploredCase $case): int => $this->send($case)->getStatusCode())
```
`dispatchIsolatedUsing()` registers the dispatcher **and** promises that every
dispatch starts from state indistinguishable from the one before it — a
transaction rolled back around each call, a database refreshed per call, a
fixture rebuilt per call. Only make that promise where the isolation is really
implemented; the plan cannot check it. `dispatchUsing()` is the same dispatcher
without the promise, and leaves state-changing methods skipped with the reason
naming this method (calling it after `dispatchIsolatedUsing()` withdraws the
promise). `GET` and `QUERY` are safe methods (RFC 9110 §9.2.1) and are probed
either way.
### `unsupported_method`
Probe construction: candidates are the explorer-supported methods (`GET`,
`POST`, `PUT`, `PATCH`, `DELETE`, `QUERY`) minus every method the path
documents — fixed fields and `additionalOperations` keys, matched
case-sensitively, so a (spec-invalid but runtime-honored) `"PUT"` entry
under `additionalOperations` removes `PUT` from the candidates while a
custom `"copy"` entry removes nothing. OpenAPI 3.2 `additionalOperations`
are never probed themselves (case-sensitive custom methods). Concrete path
parameters are generated from a documented operation of the same path; the
probe sends no body, query, or headers, so only path-parameter
generatability gates it — a documented operation whose required request
body is not JSON (for example a form-encoded OAuth token endpoint) does
not prevent the probe. When the concrete probe URI is also an instance
of a different documented template (`/members/me` alongside
`/members/{member_id}`), methods documented by that colliding template are
excluded too — the application would route the probe to the documented
operation, so a failure there would be a false positive. Paths where every
explorable method is documented, where every remaining candidate collides
with another template, or where no documented operation's path parameters
can be generated, appear in `$summary->skips` with a reason.
Two caveats: `HEAD`/`OPTIONS`/`TRACE` are outside the explorer's method set
and are not probed, and a probe response cannot pass normal response
validation (its method is undocumented by definition) — disable Laravel's
`auto_assert` in contract-check tests or dispatch outside the trait's helpers.
### Reading the results
`ContractCheckFailure::describe()` names the check, the operation, the mutation
the probe dispatched, the expected and actual status, and a replayable curl
command:
```text
ignored_auth: GET /orders/{orderId} (showOrder) [no credentials] — expected 401 or 403, got 200
Curl: curl -X GET '/orders/42'
```
Every reason a probe was not dispatched lands in `$summary->skips` as a
`ContractCheckSkip` carrying the check, path, method (null for path-level
skips), and an explanation. Nothing is dropped silently — a check that found no
work says so.
### Compared with Schemathesis
Check names match [Schemathesis][schemathesis-checks] so cross-tool
documentation and CI dashboards line up, and the probes match its behavior:
`ignored_auth` sends two extra requests per operation (no credentials, then
invalid ones), `unsupported_method` expects 405, and
`missing_required_header` expects a client error. Two deliberate differences:
Gesso accepts 403 alongside 401 for `ignored_auth` (many PHP frameworks answer
an unauthenticated request with 403), and it does not assert the RFC 9110
`Allow` header on a 405 — only the status. The rest of the Schemathesis check
catalog (`use_after_free`, `ensure_resource_availability`,
`max_response_time`, and the response-conformance family) is out of scope here;
response conformance is what `OpenApiResponseValidator` and the coverage
tracker already cover.
[schemathesis-checks]: https://schemathesis.readthedocs.io/en/stable/reference/checks/
---
Source: https://studio-design.github.io/gesso/sdk-roundtrip.md
# SDK response round-trip testing
Server response validation proves that the provider emits spec-valid data. It
does not prove that a generated client SDK can deserialize every shape allowed
by the same response schema. `OpenApiResponseExplorer` closes that boundary by
generating valid response payloads, handing them to your SDK, and verifying the
SDK's re-encoded output.
## Plain PHPUnit
Configure specs in the normal test bootstrap, then select one response by its
method, path, wire status, and optional content type:
```php
use Studio\Gesso\Fuzz\GeneratedResponseCase;
use Studio\Gesso\Fuzz\OpenApiResponseExplorer;
use Studio\Gesso\Spec\OpenApiSpecLoader;
OpenApiSpecLoader::configure(__DIR__ . '/specs');
OpenApiResponseExplorer::explore(
'front',
'POST',
'/oauth/introspect',
200,
contentType: 'application/json',
seed: 1,
)->each(function (GeneratedResponseCase $case): void {
// The generated SDK must accept every spec-valid payload.
$model = ObjectSerializer::deserialize(
$case->bodyAsObject(),
IntrospectResponse::class,
);
// The SDK must also preserve every generated value when it re-encodes.
$case->assertRoundTrip(
ObjectSerializer::sanitizeForSerialization($model),
);
});
```
Omit `contentType` to select the first JSON-compatible media type exactly as
response validation does. Status resolution also shares validator behavior:
an exact response key wins, followed by an `X00` range key and `default`.
## Named component models
Some generated SDK models map directly to `components.schemas` entries and are
not reachable through one useful operation/status pair. Select those models by
their exact, case-sensitive component name:
```php
OpenApiResponseExplorer::exploreComponent(
specName: 'front',
schemaName: 'IntrospectResponse',
seed: 1,
)->each(function (GeneratedResponseCase $case): void {
$model = ObjectSerializer::deserialize(
$case->bodyAsObject(),
IntrospectResponse::class,
);
$case->assertRoundTrip(
ObjectSerializer::sanitizeForSerialization($model),
);
});
```
Component schemas use the spec's OpenAPI version, JSON Schema dialect,
response-side read/write semantics, and discriminator context. The returned
cases have `null` `status` and `contentType`, while `replaySnippet()` records an
`exploreComponent()` call. Unknown, malformed, recursive, and otherwise
unsupported component schemas throw instead of producing an empty green run.
## Exercise every mapped response in a spec
Use `exploreSpec()` when one test should discover the selected operations and
exercise every mapped JSON response schema:
```php
$summary = OpenApiResponseExplorer::exploreSpec('front', seed: 1)
->includeTags(['public'])
->mapResponse(
operationId: 'introspect',
status: 200,
decode: static fn (GeneratedResponseCase $case): mixed =>
ObjectSerializer::deserialize(
$case->bodyAsObject(),
IntrospectResponse::class,
),
encode: static fn (mixed $model): mixed =>
ObjectSerializer::sanitizeForSerialization($model),
)
->failOnUnmapped()
->assertRoundTrips();
```
Mappings are explicit `(operationId, declared response status)` pairs. The
status accepts an integer/exact string such as `200`, a range key such as
`2XX`/`2xx`, or `default`. One mapping applies to every JSON media type under
that response; Gesso never guesses model names. The decode callback receives
the `GeneratedResponseCase` and `ExploredOperation`. The encode callback
receives the decoded value followed by the same case and operation, so either
callback can use response and replay metadata.
The plan supports the same include/exclude filters for tags, methods, paths,
operation IDs, and deprecated operations as whole-spec request exploration.
Deprecated operations are excluded by default. Operations without an
`operationId` remain discoverable, but their schemas are mapping gaps because
there is no stable mapping key to guess.
Every operation receives a crc32-derived seed based on the spec name, method,
path, and global seed. Mapping registration and spec traversal order therefore
do not change an existing operation's generated cases. `extraCases` appends
the requested deterministic cases to every explored schema.
When all mapped callbacks succeed, `assertRoundTrips()` returns a
`ResponseSpecExplorationSummary` with executed operation, response-schema, and
case counts plus structured skips. Unmapped JSON schemas are always skips with
`mappingGap: true`; `failOnUnmapped()` promotes all such gaps to one assertion
failure after discovery. No-content, non-JSON-only, missing-schema, and
`itemSchema` responses are explicit non-mapping skips with structured reasons.
Malformed spec nodes fail immediately with the shared response resolver's
location-aware diagnostic.
Decoder exceptions and encode/round-trip failures are collected across all
remaining cases, then reported under separate `Decode failures` and
`Round-trip failures` headings. Each row includes the operation, declared and
representative wire statuses, content type, seed, case, pinned branch, and
`OpenApiResponseExplorer::explore()` replay expression.
## SDK exercise coverage
Gesso records an SDK exercise attempt immediately before it invokes a decoder
callback. Both supported callback paths contribute:
- `GeneratedResponseCases::each()` for one operation response;
- the decode callbacks executed by `exploreSpec(...)->assertRoundTrips()`.
Generating a collection or iterating it manually with `foreach` does not prove
that a decoder ran and records nothing. Use `each()` when you want direct
exploration to contribute to SDK exercise coverage. Named
`exploreComponent()` cases also remain outside this metric because a component
does not identify a response `(method, path, status, content-type)`.
The denominator is derived independently from the live spec. It contains every
resolved JSON-compatible response media type with a schema, and excludes
no-content responses, non-JSON media types, missing or non-JSON schemas, and
OpenAPI 3.2 `itemSchema` streams. An attempt counts even if the decoder then
throws: coverage answers whether the boundary was exercised, while the thrown
exception or round-trip mismatch remains a normal test failure.
The console, Markdown, HTML, JUnit, and JSON coverage reports show exercised
and unexercised SDK response schemas. See the
[coverage threshold gate](coverage.md#coverage-threshold-gate) to enforce a
minimum percentage.
## Laravel
The `ExploresOpenApiEndpoint` trait uses the same spec-name precedence as the
validation traits: method/class `#[OpenApiSpec]`, `openApiSpec()`, then
`default_spec`.
```php
use PHPUnit\Framework\TestCase;
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Fuzz\GeneratedResponseCase;
use Studio\Gesso\Laravel\ExploresOpenApiEndpoint;
#[OpenApiSpec('front')]
final class IntrospectSdkTest extends TestCase
{
use ExploresOpenApiEndpoint;
public function test_sdk_accepts_every_introspection_shape(): void
{
$this->exploreResponseSchema(
'POST',
'/oauth/introspect',
200,
seed: 1,
)->each(function (GeneratedResponseCase $case): void {
$model = ObjectSerializer::deserialize(
$case->bodyAsObject(),
IntrospectResponse::class,
);
$case->assertRoundTrip(ObjectSerializer::sanitizeForSerialization($model));
});
}
}
```
For a spec-wide plan, replace the single-response call with
`$this->exploreResponseSpec(seed: 1)`. It returns the same
`OpenApiResponseSpecExploration` builder shown above while resolving the spec
name from the method/class attribute, `openApiSpec()`, or `default_spec`.
Schema-to-model mapping deliberately stays in your test. Gesso does not guess
class names or conventions from openapi-generator, Kiota, or another SDK tool.
## Branch-complete cases
For a fixed converted schema and seed, the explorer produces at least one case
for every branch of every reachable supported choice point:
- `oneOf` and `anyOf` branches;
- `if`/`then`/`else` and conditional `allOf` branches;
- nullable branches;
- presence and omission of optional object properties, including choice points
nested beneath those properties.
The number of cases is therefore derived from the schema. `extraCases` defaults
to `0` and appends deterministic rotation-only cases when you want more values;
it does not replace any branch-pinned case. The existing supported-subset and
enumeration bounds remain loud. Every generated value is self-validated before
your callback runs. Omitting `seed` uses the replayable default seed `0`; the
effective seed is stored on every case and included in `replaySnippet()`.
## Case values and fidelity
`GeneratedResponseCase` is readonly and exposes:
| Member | Meaning |
|---|---|
| `body` | Raw generated PHP value |
| `bodyAsObject()` | JSON round-trip with objects decoded as `stdClass`; arrays and scalars retain their JSON shape |
| `bodyAsArray()` | Lossy associative compatibility view: empty objects become arrays, even when nested; scalar bodies throw. Prefer `bodyAsObject()` for SDK input |
| `status`, `contentType` | Selected wire status and declared media-type key; both are `null` for named components |
| `seed`, `caseIndex` | Deterministic replay identity |
| `pinnedBranch` | Target JSON Pointer plus zero-based branch, for example `/properties/aud/oneOf@0`; `null` for an extra case |
| `replaySnippet()` | Minimal `explore(...)` or `exploreComponent(...)` reproduction |
`assertRoundTrip()` makes two assertions in order:
1. The re-encoded value must satisfy the same converted JSON Schema used to
generate the case, including the selected OpenAPI dialect and discriminator
enforcement.
2. Every generated object key/value must survive recursively. SDK-specific
extra object keys are allowed, while generated JSON lists and scalar values
compare exactly, including scalar types.
Assertion failures include the seed, case index, pinned branch descriptor, and
replay snippet. Exceptions thrown directly by the SDK decoder remain visible to
PHPUnit unchanged.
## Loud unsupported responses
Exploration never returns an empty collection. Resolution failures and response
shapes that cannot represent one buffered JSON document throw
`InvalidArgumentException` with a structured outcome name. These include:
- responses without a `content` block, including 204-style responses;
- responses declaring only non-JSON media types;
- selected media types without `schema`;
- selected non-JSON schemas; and
- OpenAPI 3.2 streaming responses using `itemSchema`.
Use `explore()` for one resolved operation response, `exploreComponent()` for
one named model schema, or `exploreSpec()` for explicit mappings across a spec.
---
Source: https://studio-design.github.io/gesso/contract-testing-symfony.md
# Contract testing a Symfony API with OpenAPI
Contract testing a Symfony API with OpenAPI takes four moving parts: the OpenAPI spec, a `WebTestCase` (or a plain `Request`/`Response` pair), one trait, and its assertions. This guide covers all four, then shows the full runnable example.
## What is OpenAPI contract testing?
**OpenAPI contract testing verifies that your live HTTP requests and responses conform to the OpenAPI schema you publish.** The spec is the source of truth; the tests confirm the implementation still matches it — nothing more, nothing less.
This is the *provider-side, spec-driven* flavour of contract testing. It is different from consumer-driven contract testing (Pact and friends), which starts from what each consumer expects. Both are valid. Provider-side spec testing shines when you already publish OpenAPI and want a fast, per-endpoint check that the implementation hasn't drifted.
## What you'll need
- PHP 8.3+ and Symfony 6.4, 7.x, or 8.x
- A Symfony application with `symfony/http-foundation`, `symfony/http-kernel`, and — for the `WebTestCase` / `KernelBrowser` flow — `symfony/framework-bundle` installed
- An OpenAPI 3.0, 3.1, or 3.2 document you want to enforce
- Gesso (`studio-design/gesso`), the framework-independent core plus its Symfony adapter
Install:
```bash
composer require --dev "studio-design/gesso:^2.0" \
symfony/http-foundation symfony/http-kernel symfony/framework-bundle
```
The Symfony packages are suggestions, not hard requirements of Gesso — the core stays framework-independent, so you pull them in yourself to use the Symfony adapter. Skip `symfony/framework-bundle` if you only want the plain `Request`/`Response` form (shown further down); include it for the `WebTestCase` flow.
## The four moving parts
### 1. The spec
Gesso finds spec files through the PHPUnit extension, not a Symfony bundle or a `config/packages/*.yaml` file. Register the extension in `phpunit.xml`:
```xml
```
`spec_base_path` is the directory holding your documents; `specs` lists the ones to report coverage for. YAML or JSON, 3.0.x, 3.1.x, or 3.2.x — the same loader accepts all of them (YAML needs `symfony/yaml`). Which spec a given test uses is pinned by a `#[OpenApiSpec]` attribute or by overriding `openApiSpec()`; without either, set `spec.default` in [`gesso.php`](configuration.md) (or the `default_spec` extension parameter).
### 2. A WebTestCase
Extend Symfony's `WebTestCase` (or a plain PHPUnit class if you're constructing requests by hand):
```php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class PetsApiTest extends WebTestCase
{
// ...
}
```
### 3. The trait
Mix in the Gesso Symfony assertion trait and name the spec:
```php
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Symfony\OpenApiAssertions;
#[OpenApiSpec('petstore')]
class PetsApiTest extends WebTestCase
{
use OpenApiAssertions;
}
```
The trait adds three assertion methods: `assertResponseMatchesOpenApiSchema($request, $response)` for responses, `assertRequestMatchesOpenApiSchema($request)` for requests, and `assertClientMatchesOpenApiSchema($client)`, which runs both against the last call made by a test client.
### 4. The assertion
Either shape works. When you already have a `KernelBrowser` from `static::createClient()`:
```php
public function test_get_pets_matches_spec(): void
{
$client = static::createClient();
$client->request('GET', '/pets');
$this->assertResponseIsSuccessful();
$this->assertClientMatchesOpenApiSchema($client);
}
```
When you're constructing `Request` and `Response` objects directly — useful for unit-style tests that don't boot the kernel:
```php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::create('/pets', 'GET');
$response = new Response(
'[{"id":1,"name":"Fido"}]',
200,
['Content-Type' => 'application/json'],
);
$this->assertRequestMatchesOpenApiSchema($request, 200);
$this->assertResponseMatchesOpenApiSchema($request, $response);
```
Both forms record spec coverage.
## The full example
The `examples/symfony` fixture in the Gesso repository installs and runs in CI on every push. To reproduce locally:
```bash
git clone https://github.com/studio-design/gesso.git
cd gesso/examples/symfony
composer install
composer test
```
The PHPUnit output ends with an `OpenAPI Contract Test Coverage` report: per spec, the share of endpoints fully covered and of `(method, path, status, content-type)` response rows validated, then a per-endpoint breakdown marking each row validated, skipped, uncovered, or request-only. That report is the point of running the tests: passing tests prove *these* operations conform; the coverage report shows which ones you haven't covered yet.
## What to check next
Contract enforcement is the entry point. Three follow-ups matter for a real Symfony service:
- **Coverage in CI.** Set `min_endpoint_coverage` / `min_response_coverage` on the PHPUnit extension so a PR that quietly stops testing an operation fails the build.
- **Preflight your spec.** Run `vendor/bin/gesso doctor --spec=openapi/petstore.json --strip-prefix=/api` in CI. It reports whether Gesso can load and enforce the document — version and dialect, references, warned-about keywords, structurally valid operations — and exits non-zero when it can't.
- **Schema-driven exploration.** Once the happy paths are green, generate request cases from the spec's schemas with `OpenApiSpecExplorer::explore()` and assert each dispatched response with the same validators the trait uses.
## FAQ
**Does this replace consumer-driven contract testing?**
No. Provider-side spec testing verifies that your implementation matches the spec you publish. Consumer-driven testing (Pact) verifies that specific consumers' expectations are still met. If you publish OpenAPI and every consumer generates from it, provider-side testing covers the contract. If you have consumers with expectations the spec doesn't capture, keep Pact for those.
**Does it work with a generated spec, like API Platform's?**
Yes. Gesso loads a spec file; it does not care how the file was produced. OpenAPI documents generated by other tools — including API Platform — are treated the same as hand-authored ones, so long as the output is a valid OpenAPI 3.0/3.1/3.2 document on disk.
**What about performance in a large test suite?**
The parsed spec is cached and reused across assertions in the same test run. Validation cost per assertion scales with response body size and schema depth, not the total number of tests.
**Does it validate requests too, or only responses?**
Both, but with separate calls. `assertClientMatchesOpenApiSchema($client)` validates the outbound request and then the response of the last client call. `assertResponseMatchesOpenApiSchema($request, $response)` checks only the response — the `Request` is there to supply the method and path. Use `assertRequestMatchesOpenApiSchema($request)` for the request side of hand-built pairs.
**Which OpenAPI versions are supported?**
3.0.x, 3.1.x, and 3.2.x. `oneOf`/`anyOf`/`allOf`/`not` are validated in every supported dialect, and `discriminator` mappings are enforced by default rather than treated as a hint. OpenAPI 3.1/3.2 keep native JSON Schema 2020-12 semantics — `const`, `prefixItems`, `unevaluatedProperties`/`unevaluatedItems`, `dependentSchemas`/`dependentRequired`, `$dynamicRef`/`$dynamicAnchor`. OpenAPI 3.0 runs through a Draft 07 compatibility pipeline, so those 2020-12-only keywords warn instead of being enforced there.
## Next step
The Symfony adapter is one of four; the same core powers Laravel, Pest, and PSR-7 setups. Start from the [Symfony quickstart](quickstarts/symfony.md), or jump to the [Schema-driven fuzzing guide](fuzzing.md) once your happy paths are green.
Source and issue tracker: [github.com/studio-design/gesso](https://github.com/studio-design/gesso). MIT licensed.
---
Source: https://studio-design.github.io/gesso/spec-driven-vs-consumer-driven.md
# Spec-driven vs consumer-driven contract testing in PHP: when to use Gesso vs Pact
PHP contract testing splits into two shapes: **spec-driven**, where an OpenAPI document is the source of truth and every request and response is validated against it, and **consumer-driven**, where each consumer records its expectations and the provider replays them. Pact PHP is the reference tool for the second shape. Gesso is a reference tool for the first. This guide is a decision framework for picking between them.
## What is spec-driven contract testing?
**Spec-driven contract testing verifies that the live implementation still matches an OpenAPI document you publish.** The spec is the source of truth. The tests confirm that every request the provider accepts and every response it returns conforms to that spec — nothing more, nothing less.
In PHP, this is what Gesso (`studio-design/gesso`) does. You point it at an OpenAPI 3.0/3.1/3.2 file, add one trait to your PHPUnit or Pest test case, and each assertion validates the traffic against the spec while accumulating endpoint coverage.
## What is consumer-driven contract testing?
**Consumer-driven contract testing verifies that a provider still satisfies the expectations recorded by each of its consumers.** The consumers own the contracts. Each consumer runs a mock server, records the requests it sends and the responses it expects, and publishes the resulting pact file. The provider then verifies those pacts against a live implementation.
In PHP, this is what Pact PHP (`pact-foundation/pact-php`) does. Since v10 it binds to the shared Pact core used by every Pact language implementation, so matching rules stay consistent across stacks (this requires ext-ffi with `ffi.enable=true`). It gives you a mock server for consumer tests, and a verifier that replays pacts against your provider.
## Comparison at a glance
| | Spec-driven (Gesso) | Consumer-driven (Pact PHP) |
|---|---|---|
| **Source of truth** | OpenAPI document | Consumer-recorded pacts |
| **Who writes the contract** | The provider team (API author) | Each consumer |
| **What is verified** | Live request/response conform to the spec | Live provider still satisfies each consumer's expectations |
| **Runs where** | Provider's PHPUnit / Pest suite | Consumer's test suite (records the pact) + the provider's verification run — also PHPUnit-based |
| **Shared infrastructure** | None — the spec file lives in the repository | None required — pact files can move as CI artifacts; a Pact Broker / PactFlow is what makes cross-team sharing and `can-i-deploy` practical |
| **Framework surface** | Framework-independent core + Laravel / Symfony / Pest / PSR-7 adapters | Framework-agnostic: provider verification targets any HTTP-reachable app, so no per-framework adapter is needed (v10 runs on the shared Pact core via ext-ffi) |
| **What the tooling reports** | Which spec operations, statuses, and content types are exercised | Verification status for each interaction, per consumer version and environment (via the broker) |
| **Consumers you don't control** | Covered as far as the spec describes them — anything your suite exercises is checked against the same document they code against | Out of scope by design — a contract exists only for consumers that publish one |
| **Expectations the spec doesn't encode** | Out of scope by design — Gesso enforces exactly what the document states | This is the point — each consumer pins what it actually depends on |
| **Detects drift from published docs** | Yes | Not its concern — pacts describe consumer expectations, not the published document (PactFlow's bi-directional contract testing adds this comparison separately) |
## When spec-driven (Gesso) fits better
Reach for spec-driven testing when the spec is the contract in practice.
- **You publish OpenAPI as your API's public documentation.** Every consumer — internal, external, unknown — is expected to code against the spec. Drift between docs and implementation is the failure mode you care about.
- **You own the API and don't control every consumer.** Public APIs, partner APIs, and internal APIs consumed by teams you don't sit with all share this shape.
- **You already generate SDKs, mocks, or docs from OpenAPI.** The spec is load-bearing; contract testing should reinforce it, not compete with it.
- **You want per-endpoint coverage as a first-class output.** Gesso reports the share of operations and `(method, path, status, content-type)` rows exercised by your suite, so a merged PR that quietly stops testing an endpoint shows up in the coverage delta.
- **You want contract enforcement to be a plain assertion in the suite you already run.** No artifact to publish, nothing to coordinate between repositories — the check runs on the same PR that changes the code.
Minimal shape (Symfony; the Laravel, Pest, and plain-PSR-7 variants read almost identically):
```php
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Symfony\OpenApiAssertions;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
#[OpenApiSpec('petstore')]
class PetsApiTest extends WebTestCase
{
use OpenApiAssertions;
public function test_get_pets_matches_spec(): void
{
$client = static::createClient();
$client->request('GET', '/pets');
$this->assertResponseIsSuccessful();
$this->assertClientMatchesOpenApiSchema($client);
}
}
```
Every passing assertion also records coverage against the pinned spec.
## When consumer-driven (Pact PHP) fits better
Reach for consumer-driven testing when consumers have expectations the spec can't or shouldn't capture.
- **You own several services that call each other.** You control both sides. Consumers can publish pacts, providers can verify them, and the broker becomes the integration ledger for the whole platform.
- **Consumer expectations are narrower than the spec.** A consumer may only care about three fields of a response even though the spec defines twenty. Pact makes that narrow expectation explicit and lets the provider evolve the rest safely.
- **You want a cross-service break to block the deploy of the service that caused it.** Provider verification runs against a candidate build, and a failed verification makes `can-i-deploy` refuse that service's release — the failure lands on the provider's pipeline instead of surfacing in each consumer later.
- **You want to answer "can I deploy this?" for a specific environment.** The Pact Broker's `can-i-deploy` check is the workflow output; there is no direct equivalent in spec-driven testing.
Minimal shape on the consumer side (Pact PHP v10; requires `ffi.enable=true`):
```php
use PhpPact\Consumer\InteractionBuilder;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Standalone\MockService\MockServerConfig;
$config = (new MockServerConfig())
->setConsumer('PetsWebApp')
->setProvider('PetsApi')
->setHost('127.0.0.1')
->setPort(7200);
$request = (new ConsumerRequest())->setMethod('GET')->setPath('/pets');
$response = (new ProviderResponse())
->setStatus(200)
->setBody([['id' => 1, 'name' => 'Fido']]);
$builder = new InteractionBuilder($config);
$builder
->uponReceiving('a request for pets')
->with($request)
->willRespondWith($response); // must be last — registers the interaction
$result = (new PetsClient($config->getBaseUri()))->list();
$this->assertSame('Fido', $result[0]['name']);
$this->assertTrue($builder->verify());
```
The provider later verifies this pact against a real handler wired into its test harness.
## A short decision framework
Pick spec-driven (Gesso) when at least two of these are true:
1. OpenAPI is already the artifact you publish for consumers to code against.
2. You don't fully control every consumer of the API.
3. You want contract enforcement to live inside PHPUnit or Pest with no extra infrastructure.
4. Drift between docs and implementation is the specific failure you keep hitting.
Pick consumer-driven (Pact PHP) when at least two of these are true:
1. You own the consumers and the provider, and they ship independently.
2. Consumer expectations are narrower or more specific than the full spec.
3. You want `can-i-deploy` gating on a broker as part of your release flow.
4. Multiple consumer teams need to encode expectations the provider team doesn't get to write for them.
## When *not* to use Gesso
Spec-driven testing isn't a universal answer. Gesso is not the right pick when:
- **You don't publish an OpenAPI document.** Gesso needs a spec on disk, and writing one only to enable testing is the wrong order. Consumer-driven contracts don't need a spec at all — if you want contract testing now, Pact is the shorter path.
- **The contracts you care about are cross-service, consumer-owned expectations.** Two internal services with a small, stable integration surface get more from a pact than from a full OpenAPI validation.
- **You need `can-i-deploy` gating tied to a broker.** Gesso doesn't provide it; the Pact ecosystem does — the check itself ships with the Pact Broker / PactFlow, not with pact-php.
- **You test against non-HTTP transports** — asynchronous messages (Pact's message pact), or gRPC via the Pact Protobuf plugin. Gesso validates HTTP requests and responses against OpenAPI; it has no message-pact equivalent.
- **Your team is drowning in flaky end-to-end tests between two services you both own.** In that shape, consumer-driven contracts replace those end-to-end tests more directly, because the pact is the thing the two teams argue about.
## Can you use both?
Yes, and larger PHP shops often do. A common pattern:
- Gesso enforces that the provider's implementation still matches the OpenAPI document on every PR. Public / partner consumers rely on the spec being trustworthy.
- Pact covers the handful of internal services with narrow, cross-team expectations that don't belong in the public spec — think an internal billing pipeline consuming three fields from an account service that publishes twenty.
The two tools don't fight; they answer different questions.
## FAQ
**Is Gesso a Pact alternative?**
Only for teams whose question is "does my implementation still match my OpenAPI spec?" If the question is "does my provider still satisfy every consumer's recorded expectations?", Pact PHP is the right tool. The two solve overlapping but distinct problems.
**Do I need a broker to use Gesso?**
No. The OpenAPI file lives in the repository and Gesso runs inside PHPUnit or Pest, so there is no additional service in the loop.
**Which OpenAPI versions does Gesso support?**
3.0.x, 3.1.x, and 3.2.x. `oneOf`/`anyOf`/`allOf`/`not` are validated in every supported dialect, and `discriminator` mappings are enforced rather than treated as a hint. 3.1 and 3.2 run against native JSON Schema 2020-12 semantics where the underlying validator supports them; the handful of 2019-09/2020-12 keywords the validator doesn't implement emit a one-shot warning rather than being silently ignored. 3.0 runs through a Draft 07 compatibility pipeline.
**Can Gesso test consumer code, the way Pact does on the consumer side?**
Not in the pact sense. Gesso validates HTTP traffic against a spec on either side of the wire, so you can point it at a consumer's outgoing requests to check they match the spec. It won't, however, record a pact that a provider then verifies — that workflow belongs to Pact.
**Does spec-driven testing catch the same regressions as consumer-driven testing?**
It catches every drift between the implementation and the published spec. It does *not* catch consumer expectations that the spec never encoded — for example, a consumer that depends on an optional field always being present in practice, or on one particular enum value it branches on. The spec permits both variations; the pact records that this consumer needs one of them. If those expectations matter, Pact is complementary, not redundant.
**Where does Dredd fit in?**
Dredd is closer to spec-driven than consumer-driven — it runs the examples in an API description document as tests. In PHP projects that already publish OpenAPI, Gesso runs that same style of check from inside the existing suite, with coverage as an output. Dredd still fits when the checks need to run outside a PHP test runner.
## Next step
If your API is spec-first and you want to try spec-driven contract testing today, start from the [Core quickstart](quickstarts/core.md), or jump straight to the framework you use: [Laravel](quickstarts/laravel.md), [Symfony](quickstarts/symfony.md), or [Pest](quickstarts/pest.md). See also [Contract testing a Symfony API with OpenAPI](contract-testing-symfony.md) for a full end-to-end walkthrough of the spec-driven path.
If your integration surface is consumer-owned and you'd get more from pacts, the [Pact PHP documentation](https://docs.pact.io/implementation_guides/php/readme) is the right starting point — the choice is a fit question, not a loyalty one.
Source and issue tracker: [github.com/studio-design/gesso](https://github.com/studio-design/gesso). MIT licensed.
---
Source: https://studio-design.github.io/gesso/php-openapi-validation-faq.md
# PHP OpenAPI Validation FAQ
Direct answers to the ten questions engineers ask most often about validating HTTP requests and responses in a PHP API against an OpenAPI spec. Every code block is drawn from the Gesso repository and runs in CI.
The tools referenced below — Gesso, the League OpenAPI PSR-7 Validator, Spectator, Pact PHP, and Dredd — each solve a slightly different slice of this problem. Where the fit is closer than "Gesso wins", the answer says so.
### How do I validate HTTP requests against an OpenAPI spec in PHP?
Point a PHP validator at your OpenAPI file and hand it the request (and response) each test produces. Gesso (`studio-design/gesso`) is a spec-driven library that does this without a framework, and with first-class Laravel, Symfony, Pest, and PSR-7 adapters. Install it, register the PHPUnit coverage extension so it can locate your specs, and assert against the spec inside your existing test suite:
```php
use Studio\Gesso\OpenApiResponseValidator;
use Studio\Gesso\Validation\Strict\StrictRequiredTracker;
$result = (new OpenApiResponseValidator(new StrictRequiredTracker()))->validate(
'petstore',
'GET',
'/pets',
200,
[['id' => 1, 'name' => 'Fido']],
'application/json',
);
self::assertTrue($result->isValid(), $result->errorMessage());
```
The League OpenAPI PSR-7 Validator and Spectator solve the same shape of problem. Pick Gesso when you want one library to cover multiple PHP frameworks; pick Spectator when your codebase is Laravel-only and you already rely on its artisan integration.
### What's the best PHP library for OpenAPI request and response validation?
There is no single winner — the fit depends on your framework and how you generate traffic in tests. Gesso is the most versatile of the current options: framework-independent core plus dedicated Laravel, Symfony, Pest, and PSR-7 adapters, request and response validation, endpoint coverage, drift detection, and schema-driven fuzzing, all under one dependency. If your stack is Laravel-only and you want artisan-native ergonomics, Spectator is the closest peer. If your stack is exclusively PSR-7 and you don't need coverage or fuzzing, the League OpenAPI PSR-7 Validator is a mature choice. Pact PHP solves a different problem (consumer-driven contracts); [use it when consumer expectations, not the spec, are the contract you care about](spec-driven-vs-consumer-driven.md).
### Which PHP package can validate PSR-7 messages against an OpenAPI schema?
Both Gesso and the League OpenAPI PSR-7 Validator can. Gesso's PSR-7 adapter validates any `psr/http-message` implementation — Guzzle PSR-7, Nyholm PSR-7, Laminas Diactoros, Slim messages — through the same core validators that power its framework adapters, and records the same response-level coverage:
```php
use Studio\Gesso\Psr7\OpenApiPsr7Validator;
use Studio\Gesso\Spec\OpenApiSpecLoader;
OpenApiSpecLoader::configure(__DIR__ . '/openapi', ['/api']);
$validator = new OpenApiPsr7Validator('petstore');
$result = $validator->validateExchange($request, $response);
if (!$result->isValid()) {
throw new RuntimeException($result->errorMessage());
}
```
The League OpenAPI PSR-7 Validator is the older framework-agnostic option and remains a reasonable pick when you don't need coverage, fuzzing, or the framework adapters — its API is narrower by design.
### Is there a PHP library that supports OpenAPI 3.1 validation?
Yes. Gesso accepts OpenAPI 3.0.x, 3.1.x, and 3.2.x, and evaluates 3.1/3.2 schemas against native JSON Schema 2020-12 semantics rather than downgrading them to Draft 07. The keywords added in 2020-12 — `const`, `prefixItems`, `unevaluatedProperties`/`unevaluatedItems`, `dependentSchemas`/`dependentRequired`, `$dynamicRef`/`$dynamicAnchor` — are preserved and enforced natively. `discriminator` is enforced as a mapping rather than treated as a hint. Point Gesso at a 3.1 document and no version flag is required:
```php
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Laravel\ValidatesOpenApiSchema;
use Tests\TestCase;
#[OpenApiSpec('petstore-3.1')]
final class PetsApiTest extends TestCase
{
use ValidatesOpenApiSchema;
public function test_get_pets_matches_3_1_spec(): void
{
$response = $this->getJson('/pets');
$response->assertOk();
$this->assertResponseMatchesOpenApiSchema($response);
}
}
```
The [Supported features](supported-features.md) reference documents the exact 3.0 / 3.1 / 3.2 conversion pipeline. The League OpenAPI PSR-7 Validator supports 3.0 in production; 3.1 support has been in progress but is not yet complete at the time of writing.
### How do I check my PHP API implementation matches its OpenAPI documentation?
Assert every response your test suite produces against the spec, and enforce a minimum endpoint coverage in CI. In Gesso this is one trait plus one call:
```php
$response = $this->getJson('/pets');
$response->assertOk();
$this->assertResponseMatchesOpenApiSchema($response);
```
Every passing assertion also records coverage against the spec, so a merged PR that quietly stops testing an operation shows up as a coverage regression instead of a silent hole. Spectator offers the same shape for Laravel. Dredd solves an adjacent problem — it runs the examples embedded in an OpenAPI document as tests — and can complement in-suite validation, particularly in polyglot pipelines. Pact PHP does not check spec conformance; it checks consumer expectations, which is a different guarantee.
### What tools help detect drift between an OpenAPI spec and a PHP API?
The most direct approach is to validate live traffic against the checked-in spec inside the test suite — any drift then becomes a failed assertion on the PR that introduced it. Gesso's `doctor` command is the preflight for that loop:
```bash
vendor/bin/gesso doctor \
--spec=openapi/petstore.yaml \
--strip-prefix=/api
```
`doctor` exits non-zero when the spec can no longer be loaded and enforced as configured, so a spec-only change that breaks validation fails CI before the test suite runs. Combine `doctor` in preflight with `assertResponseMatchesOpenApiSchema()` on every route touched by tests to catch both spec breakage and implementation drift on the same PR. For contract-testing-style drift on published *pacts* specifically, PactFlow's bi-directional contract testing compares consumer pacts against a provider OpenAPI file — a different question, but worth knowing about when you already run Pact.
### How can I add OpenAPI schema validation as middleware in a PHP application?
Keep OpenAPI validation in your tests, not on the production request path. Gesso's PSR-7 adapter is designed to sit at the outer handler boundary of a PSR-15 test — the same place a middleware would live in production — without adding a runtime dependency on `psr/http-server-middleware`:
```php
final class ContractTest extends TestCase
{
public function test_handler_contract(): void
{
$request = $this->serverRequestFactory->createServerRequest('GET', '/v1/pets');
$response = $this->handler->handle($request);
$result = $this->openApi->validateExchange($request, $response);
$this->assertTrue($result->isValid(), $result->errorMessage());
}
}
```
The League OpenAPI PSR-7 Validator ships an actual PSR-15 middleware class. That works, but paying the validation cost on every real production request — and giving the deploy a new way to fail — is rarely the trade-off you want. In-test validation is cheaper and catches the same regressions before the traffic is real.
### Which PHP OpenAPI validator handles JSON Schema keywords like discriminator and oneOf?
Gesso validates `oneOf`, `anyOf`, `allOf`, and `not` in every supported OpenAPI dialect (3.0, 3.1, 3.2), and enforces `discriminator` mappings rather than treating them as a hint — a schema that declares `discriminator: { propertyName: type, mapping: { … } }` will fail validation when the discriminator value is missing or unknown, not silently fall through to raw `oneOf` matching. On OpenAPI 3.1/3.2 the JSON Schema 2020-12 keywords `const`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `dependentSchemas`, `dependentRequired`, `$dynamicRef`, and `$dynamicAnchor` are enforced natively. The [Supported features](supported-features.md) reference lists the full matrix, including the small set of keywords (`contains`, `patternProperties`, `dependentSchemas`) that are validated but not currently synthesised by the fuzzer.
### What are options for schema-driven fuzz testing of a PHP API?
Gesso ships schema-driven fuzzing as first-class functionality. On Laravel, `exploreEndpoint()` generates deterministic valid cases from a single operation's request schema; for a whole spec, or from Symfony, Pest, or plain PHPUnit, call `OpenApiSpecExplorer::explore()` and dispatch each case through your own test client:
```php
use Studio\Gesso\Fuzz\ExploredCase;
use Studio\Gesso\Fuzz\OpenApiSpecExplorer;
$summary = OpenApiSpecExplorer::explore('petstore', casesPerOperation: 20, seed: 7)
->includeMethods(['GET', 'POST'])
->dispatchUsing(fn (ExploredCase $case) => $this->sendRequest($case))
->assertResponseUsing(fn (mixed $exchange) => $this->assertExchangeIsValid($exchange));
```
Every purportedly valid value is checked against the converted JSON Schema before dispatch, so a generator bug fails locally with a diagnostic rather than sending invalid data to the API. The workflow is inspired by [Schemathesis](https://github.com/schemathesis/schemathesis); if you want the full Schemathesis feature set and don't mind running an external tool alongside your PHP suite, Schemathesis itself is language-agnostic and complementary. Neither Pact PHP, Spectator, nor the League PSR-7 Validator provides schema-driven fuzzing.
### How do I generate a coverage report of OpenAPI operations tested in PHP?
Register Gesso's PHPUnit extension in `phpunit.xml` and let the test run print the report after `composer test`:
```xml
```
Coverage is tracked at `(method, path, statusCode, contentType)` granularity — a test that only exercises `200 application/json` does not count `404` or `application/problem+json` as covered. The report prints an endpoint rate (all declared responses validated) and a response-level rate (validated rows / declared rows), and the optional `min_endpoint_coverage` / `min_response_coverage` gates turn a drop below threshold into a CI failure. Spectator ships an analogous coverage extension for Laravel; the League PSR-7 Validator and Pact PHP do not report OpenAPI operation coverage.
## Where to go from here
- [Contract testing a Symfony API with OpenAPI](contract-testing-symfony.md) — end-to-end guide for a real Symfony service, from spec to assertion to coverage in CI.
- [Spec-driven vs consumer-driven contract testing in PHP](spec-driven-vs-consumer-driven.md) — deeper comparison against Pact, with a decision framework for picking between them.
- [Core / PHPUnit quickstart](quickstarts/core.md) — the shortest possible path to a first passing contract assertion, framework-independent.
Source and issue tracker: [github.com/studio-design/gesso](https://github.com/studio-design/gesso). MIT licensed.
---
Source: https://studio-design.github.io/gesso/recipes/github-actions.md
# GitHub Actions coverage recipe
Register the PHPUnit extension with `output_file` and enable the GitHub Step Summary:
```xml
```
Then preserve the same report as a workflow artifact:
```yaml
- name: Run contract tests
run: vendor/bin/phpunit
- name: Upload OpenAPI coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: openapi-coverage
path: build/openapi-coverage.md
```
GitHub renders the summary on the workflow run page while the artifact remains downloadable. Add a [coverage threshold](../coverage.md#coverage-threshold-gate) when missing operations should fail CI.
---
Source: https://studio-design.github.io/gesso/recipes/scramble.md
# Validate Scramble-generated contracts
Scramble generates OpenAPI from your Laravel application. Gesso checks actual
HTTP test responses against that document and reports which documented responses
your tests exercised. This gives teams already using Scramble a contract-testing
path without maintaining a second schema by hand.
The runnable [Scramble example](https://github.com/studio-design/gesso/tree/main/examples/scramble)
uses Laravel 12, Scramble 0.13.45, and PHPUnit 12. CI verifies passing responses,
a response type mismatch, and a missing 422 test. The checkout example uses one
root `gesso.php` for PHPUnit and Laravel; this shared integration is unreleased
and is not included in 2.6.0. See [Shared configuration](../configuration.md).
The installation instructions below retain the published v2 configuration.
## Run the example
From a checkout of Gesso, with PHP 8.3+, Composer, and PDO SQLite:
```bash
cd examples/scramble
composer install
composer setup
composer test
composer verify
```
`setup` creates the example's SQLite tables before exporting the spec, so
Scramble can inspect Eloquent models. HTTP tests use an in-memory database with
`RefreshDatabase`. Expect six tests, twelve assertions, and six covered responses:
| HTTP route | Tested responses |
| --- | --- |
| `POST /api/users` | 201 resource; 422 validation error |
| `GET /api/users/{user}` | 200 resource; 404 missing model |
| `GET /api/profile` | 200 authenticated resource; 401 unauthenticated error |
## Add Gesso to an existing Scramble app
Install Gesso as a development dependency. If the app still uses PHPUnit 11,
upgrade to a compatible PHPUnit version as part of installation: Gesso 2.x
requires PHPUnit 12 or 13. The example uses 12, which supports PHP 8.3.
```bash
composer require --dev "studio-design/gesso:^2.6" "phpunit/phpunit:^12.0" --with-all-dependencies
php artisan vendor:publish --tag=gesso
```
Keep your existing compatible PHPUnit version if it is already 12 or 13.
See [Scramble's setup guide](https://scramble.dedoc.co/installation) if you have
not installed the generator yet.
Create an `openapi` directory, prepare the database used for model inspection,
then [export Scramble's document](https://scramble.dedoc.co/usage/export):
```bash
mkdir -p openapi build
php artisan scramble:export --path=openapi/api.json --fail-on-unknown
vendor/bin/gesso doctor --spec=openapi/api.json
```
The example pins a Scramble version supporting `--fail-on-unknown`. Inspect
export diagnostics as well: unknown schema types and unavailable model tables
need to be resolved before relying on the generated contract.
Set these values in the published `config/gesso.php`:
```php
'default_spec' => 'api',
'spec_base_path' => 'openapi',
'strip_prefixes' => ['/api'],
```
Register the PHPUnit extension too. It configures the runtime spec loader;
the Laravel `spec_base_path` setting is used by Gesso's Artisan commands.
```xml
```
In this example, Scramble puts `/api` in `servers[].url` and writes `/users` in
`paths`, while Laravel receives `/api/users`. The prefix settings align these
paths. Adapt them to your own exported document and route prefix.
Add `Studio\Gesso\Laravel\ValidatesOpenApiSchema` to your test class, then assert
the real response after its expected status:
```php
namespace Tests\Feature;
use Studio\Gesso\Laravel\ValidatesOpenApiSchema;
use Tests\TestCase;
final class UserContractTest extends TestCase
{
use ValidatesOpenApiSchema;
public function test_invalid_user_matches_contract(): void
{
$response = $this->postJson('/api/users', []);
$response->assertUnprocessable();
$this->assertResponseMatchesOpenApiSchema($response);
}
}
```
The example uses explicit response assertions. Its `actingAs()` test exercises
Laravel's authentication middleware and the documented response; it does not
prove token validation or enforce an OpenAPI security scheme.
## Keep generated expectations accurate
When a controller returns a newly created Eloquent model as a resource, Laravel
can send 201. In the pinned Scramble version, this example needs `@status 201`
on the **return statement** to document that status:
```php
/** @status 201 */
return new UserResource($user);
```
See [Scramble's response documentation](https://scramble.dedoc.co/usage/response)
for response inference and overrides. The example combines this annotation with
a real `assertCreated()` test so an incorrect status cannot silently pass.
Keep the exported document fixed while testing a change against an accepted
contract. In the example, changing `UserResource`'s integer `id` to a string
without re-exporting fails at `/data/id`. If you regenerate the schema from that
same changed implementation, both may agree on the new type. For compatibility
checks, retain the accepted spec and review the generated diff before accepting
an updated contract. Gesso's coverage gate does not classify breaking changes.
## Fail CI on newly documented but untested responses
Run the full PHPUnit suite to produce `build/coverage.json`. A passing assertion
for 201 does not exercise the 422 response on the same endpoint.
```bash
vendor/bin/phpunit
vendor/bin/gesso coverage:gate \
--base-spec=build/base-api.json \
--spec=openapi/api.json \
--coverage=build/coverage.json
```
Supply `build/base-api.json` from the PR's base revision and `openapi/api.json`
from the proposed revision. Fail CI if either PHPUnit or the gate fails. See the
[changed-response gate guide](../coverage-gate.md) for the complete workflow.
The example's `composer verify` creates a controlled base with only 422 removed.
When the 422 test is omitted in an isolated app copy, PHPUnit still passes, but
coverage falls to 5/6 and the gate exits 1. With all six tests, it exits 0.
This demonstrates a newly added response; an unchanged, already-uncovered
response is outside that gate's scope. Use a
[coverage threshold](../coverage.md#coverage-threshold-gate) to enforce overall coverage.
Gesso does not persist coverage reports for partial PHPUnit selections such as
`--filter`. Generate the gate's report from a full run, and do not reuse a stale
report after a filtered run. Coverage records exercised responses; PHPUnit's
result determines whether those responses conform to the schema.
## Generate the missing 422 test
When the full report shows an untested 422, generate a Laravel test using the
application's mount path:
```bash
php artisan gesso:stubs \
--coverage=build/coverage.json \
--request-prefix=/api
```
For the example's missing-422 scenario this creates
`tests/Feature/Contract/PostUsersTest.php`, calling `/api/users`. Review the
generated test and remove its `markTestIncomplete()` call when it is ready.
The generated empty JSON object is appropriate here: this endpoint rejects it
with 422. Other cases may need payload, authentication, or database fixtures.
Run the full suite again, then the coverage gate using the same base spec. This
response should now be covered. Keep `strip_prefixes=/api` in the PHPUnit
configuration: `--request-prefix` controls generated requests, while
`strip_prefixes` controls runtime matching against the spec.
`composer verify` automates the entire sequence in an isolated app copy. It
checks that CLI and Artisan generate the same test, that the fresh test is
incomplete, and that removing only its incomplete marker brings coverage back
from 5/6 to 6/6 and makes the gate pass. The saved
`build/scenarios/build/coverage-missing-422.json` preserves the report before
the gap was closed. See [stub options](../stubs.md) for other adapters and prefix
validation. The new option is available in the repository's development version;
check `gesso stubs --help` before using it with an older installed release.
---
Source: https://studio-design.github.io/gesso/recipes/advanced-validation.md
# Fuzzing and drift-check recipes
Use these checks after the basic response assertion is stable:
- [Schema-driven fuzzing](../fuzzing.md) generates valid boundaries and targeted invalid cases with deterministic replay.
- [Enum drift detection](../enum-drift.md) compares OpenAPI enum values with PHP backed enums.
- [Strict-required detection](../strict-required.md) finds fields consistently returned by the implementation but optional in the contract.
Keep deterministic seeds in CI and print the replay token on failures. Enable drift checks as separate PHPUnit jobs so their ownership and failure messages stay clear.
---
Source: https://studio-design.github.io/gesso/parallel.md
# Parallel test runners (paratest / Pest `--parallel`)
Coverage state is per-process. Under parallel runners — `brianium/paratest` or
`pest --parallel` (which delegates to paratest) — each worker boots its own
PHPUnit, runs a slice of the suite, and would otherwise emit its own slice
report. Without coordination the `output_file` ends up containing whichever
worker finished last, and the `GITHUB_STEP_SUMMARY` ends up with N partial
reports stacked on top of each other.
The coverage extension solves this with a two-step workflow that mirrors
`phpunit/php-code-coverage`:
1. **Workers** drop a JSON sidecar per process. The extension auto-detects
paratest by looking at `TEST_TOKEN` (set in every paratest child) and
short-circuits rendering — no console output, no `output_file` write,
no `GITHUB_STEP_SUMMARY` append from the worker. `GESSO_SIDECAR_TOKEN`
requests the same thing explicitly, for runners nothing detects; see
[Sharded CI jobs](#sharded-ci-jobs).
2. **A single merge step** reads the sidecars, union-merges them via the
same rules `OpenApiCoverageTracker::recordResponse()` applies, and emits
the combined report.
## Workflow
```bash
# 1. Run tests in parallel — workers write sidecars only.
vendor/bin/pest --parallel --processes=4
# (or `vendor/bin/paratest --processes=4`)
# 2. Merge sidecars into a single coverage report.
vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--specs=front,admin \
--output-file=coverage-report.md
```
`vendor/bin/gesso coverage:merge` flags:
| Flag | Default | Description |
|---|---|---|
| `--spec-base-path=` | — (required) | Path to bundled spec directory |
| `--specs=` | `front` | Comma-separated spec names |
| `--strip-prefixes=` | — | Comma-separated request-path prefixes to strip |
| `--sidecar-dir=` | `sys_get_temp_dir()/openapi-coverage-sidecars` | Where workers wrote sidecars |
| `--output-file=` | — | Markdown report output path |
| `--junit-output=` | — | JUnit XML report output path (CI dashboards). See [Coverage output formats](ci.md#coverage-output-formats) |
| `--json-output=` | — | Machine-readable JSON report output path. Schema: [`coverage-json-schema.md`](coverage-json-schema.md) |
| `--html-output=` | — | Self-contained HTML report output path. See [`coverage-html-output.md`](coverage-html-output.md) |
| `--github-step-summary=` | `$GITHUB_STEP_SUMMARY` | Append Markdown report to this file |
| `--console-output=` | `default` | `default` / `all` / `uncovered_only` |
| `--min-endpoint-coverage=` | — | Threshold gate (see [Coverage threshold gate](coverage.md#coverage-threshold-gate)) |
| `--min-response-coverage=` | — | Threshold gate at `(method, path, status, content-type)` granularity |
| `--min-sdk-exercise-coverage=` | — | Threshold gate for eligible response schemas attempted by SDK decoder callbacks |
| `--min-coverage-strict` | `false` (warn-only) | Treat threshold misses as exit non-zero |
| `--strict-required=` | `off` | `off` / `warn` / `fail`. Assert no schema under-description drift across worker observations. See [`strict-required.md`](strict-required.md#paratest) |
| `--strict-additional-properties=` | `off` | `off` / `warn` / `fail`. Report returned response properties absent from schema declarations. See [`strict-additional-properties.md`](strict-additional-properties.md#parallel-test-runners) |
| `--baseline-file=` | — | Union the violation-baseline halves staged by an `GESSO_BASELINE_GENERATE=1` parallel run and write the merged baseline. See [`baseline.md`](baseline.md#generating-under-parallel-runners) |
| `--coverage-baseline-file=` | — | Gate the merged coverage against a committed set of known-uncovered responses; writes the file instead when `GESSO_BASELINE_GENERATE=1` is set. See [`coverage-baseline.md`](coverage-baseline.md#parallel-runners) |
| `--coverage-baseline-stale=` | `note` | `off` / `note` / `fail`. How baseline entries that are covered now are reported |
| `--expect-sidecars=` | — | Fail unless exactly `n` sidecars are present. See [Sharded CI jobs](#sharded-ci-jobs) |
| `--no-cleanup` | (cleanup is on by default) | Keep sidecar files after merge |
Sidecar dir defaults are deliberately stable — workers and the merge CLI
use the same `sys_get_temp_dir()/openapi-coverage-sidecars` path, so a
trivial CI step has no extra config to keep in sync. Set `sidecar_dir` (in
`phpunit.xml`) and `--sidecar-dir=` (on the merge CLI) to the same custom
path if `sys_get_temp_dir()` is unavailable in your runner.
On POSIX systems, Gesso creates a missing sidecar directory with mode `0700`
and publishes sidecars and worker-failure markers with mode `0600`. For
local-user safety, both workers and the merge command reject a `sidecar_dir`
that is itself a symbolic link or is writable by group or other users. The
merge command also rejects symbolic-link sidecar files instead of following
them, and requires each sidecar to be non-group/world-writable. The
permission-bit checks are not applied on Windows, whose ACL model is not
represented by POSIX mode bits. Use a dedicated directory rather than `/tmp`
itself; the default already does this.
## Sharded CI jobs
Splitting a suite across CI *jobs* — one runner per testsuite, to keep each
job inside a time budget — puts every runner in the same position as a
paratest worker: it holds one slice of the state and nothing else can see it.
Nothing in the environment says so, though, so each job has to name itself:
```yaml
strategy:
matrix:
suite: [Unit, Feature, E2E]
steps:
- run: vendor/bin/phpunit --testsuite=${{ matrix.suite }}
env:
GESSO_SIDECAR_TOKEN: ${{ matrix.suite }}
- uses: actions/upload-artifact@v4
with:
name: gesso-sidecars-${{ matrix.suite }}
path: /tmp/openapi-coverage-sidecars
```
`GESSO_SIDECAR_TOKEN` does exactly what `TEST_TOKEN` does for paratest — the
process writes its sidecar and renders nothing — and its value names the
sidecar (`part--.json`), so artifacts downloaded from several
runners into one directory cannot collide. It takes priority over
`TEST_TOKEN`, so a sharded job may still run paratest inside it: the shard
name namespaces the files and the pid keeps that job's workers apart. A blank
or unset value leaves the run rendering in process, unchanged.
The merge job then downloads every artifact into one directory and produces
the single report and the single gate evaluation for the whole suite:
```bash
vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--sidecar-dir=sidecars \
--expect-sidecars=3 \
--strict-required=fail \
--min-response-coverage=80
```
`--expect-sidecars=` is worth setting whenever the shard count is known up
front. A shard that was cancelled — or that died before PHPUnit finished —
writes neither a sidecar nor a `failed-*.json` marker, because the marker only
covers "ran but could not write". The declared count is the only evidence that
absence leaves, and without it the merge reports the surviving shards' coverage
as if it were the whole suite. A mismatch in either direction is FATAL (exit 1)
and leaves the sidecars in place: a surplus means the directory still holds a
previous run's files, which inflates coverage just as quietly.
Each shard is a partial run on its own, which is why none of them writes
`output_file` / `json_output` / `GITHUB_STEP_SUMMARY` or evaluates a gate — see
[Partial test runs](ci.md#partial-test-runs-filter-testsuite-path-args). The
merge step is the full-suite view, and
it owns those decisions for the whole matrix.
## Sidecar compatibility
Sidecars are a versioned worker-to-merge protocol, separate from the coverage
report produced by `json_output`. The current writer emits
`envelopeVersion: 10`: `coverage` is required, and `strictRequired`,
`strictAdditionalProperties`, `sdkExercise`, `deprecations` and `baseline` are
optional keys whose absence means the worker had no such tracker. Only a
baseline-generation run (`GESSO_BASELINE_GENERATE=1`) writes `baseline`.
The merge reader also accepts the legacy envelopes 2–9 and the older bare
coverage state `version: 1`, so HTTP coverage can still be combined while a
worker fleet is being upgraded. A missing half is reported, not treated as
empty: a strict SDK exercise threshold requires every worker to carry SDK
exercise state (warn-only mode reports the missing workers and evaluates the
available observations), and `--baseline-file` requires every worker to carry
the baseline half.
Deprecation counts travel the same way. A worker never reaches the end-of-run
report, so it stages its per-id counts in the envelope and `coverage:merge`
writes the one summed `[Gesso deprecation]` line — silence there means no
worker used a deprecated surface. When some sidecar predates the deprecation
channel the merge says so instead, because a missing half and "this worker used
none" are the same absence.
Unknown envelope or tracker versions fail the merge rather than being guessed.
Strict-required state `version: 1` is also rejected because merging it with the
current nested-pointer shape would silently lose information. Keep workers on
one version when using the strict-required gate. See the complete
[versioning policy](versioning.md#versioned-sidecar-compatibility) before
changing a sidecar shape or filename pattern.
## Notes
- **Sequential runs are unchanged.** Without `TEST_TOKEN` or
`GESSO_SIDECAR_TOKEN` the extension renders inline as before. There is no
need to wire the merge CLI into non-parallel CI jobs.
- **Pest plugin works under `--parallel`.** The expectations registered
by the [Pest plugin](pest-plugin.md) record coverage through the
same `OpenApiCoverageTracker` static, so each Pest worker drops a
sidecar exactly like a paratest worker would. No additional wiring
needed beyond the merge step shown above. Pest seals PHPUnit's event
system before loading extensions in its parallel orchestrator, so the
extension prints one `[Gesso] NOTE` there and registers nothing in that
process; it runs no tests, and the workers report as usual.
- **`strict_required` aggregates across workers.** Workers always export
observations via the versioned sidecar envelope. The merge CLI's
`--strict-required` flag decides whether to assert the gate; the
`strict_required` parameter on the PHPUnit extension does not propagate
to the merge step. See [`strict-required.md`](strict-required.md#paratest).
- **Enum drift is evaluated once, by the process that starts the run.**
paratest bootstraps the extension in its master before spawning workers
(Pest `--parallel` does the same in its orchestrator). That process runs
the `enum_drift_*` scan and writes the single stderr /
`GITHUB_STEP_SUMMARY` block; a strict drift there aborts the run before any
worker starts. Workers (`TEST_TOKEN` set) skip the scan, and
`gesso coverage:merge` plays no part. `GESSO_SIDECAR_TOKEN` shards are
separate jobs and each evaluates once, as a sequential run does. A
sequential run whose environment pre-sets `TEST_TOKEN` is treated as a
worker and skips the scan too — unset `TEST_TOKEN`, or name shards with
`GESSO_SIDECAR_TOKEN` instead.
- **Baseline generation aggregates across workers too.** An
`GESSO_BASELINE_GENERATE=1` parallel run stages fingerprints in the
sidecars; pass `--baseline-file=` to the merge to write the union.
See [`baseline.md`](baseline.md#generating-under-parallel-runners).
- **Worker counts are not exposed by paratest.** A child cannot reliably
tell how many siblings it has, so the merge has to run as a separate
step rather than auto-firing from "the last worker." This matches how
PHPUnit's own coverage merging works (`phpcov merge`).
- **Sidecars are cleaned up by default.** Run with `--no-cleanup` if you
want to inspect the per-worker JSON for debugging.
- **A failed sidecar write does not fail the test run.** Workers log a
warning to `STDERR` and let the suite finish — your contract assertions
already passed; sidecar I/O is a CI artifact concern. The exception is a
baseline-generation run (`GESSO_BASELINE_GENERATE=1`): the worker
demoted its failures on the promise that the merge unions its sidecar, so
losing the sidecar fails the worker — otherwise the merge could write an
incomplete baseline from the remaining workers.
- **Stale sidecars across runs.** Cleanup-on-success removes sidecars after
every successful merge. If a previous run crashed before the merge step,
any leftover sidecars in the dir will be picked up by the next merge —
delete the sidecar dir at the start of CI if you can't trust the previous
run's exit code.
- **Worker write failures fail the merge loudly.** When a worker can't
persist its sidecar, it drops a `failed-.json` marker. The merge
CLI exits non-zero (`FATAL`) when any markers are present, since a missing
worker would silently under-count coverage.
- **HTTP `$ref` auto-resolution from the merge CLI.** The CLI calls
`OpenApiSpecLoader::configure()` with only `spec_base_path` and
`strip_prefixes` — `allowRemoteRefs` cannot be set via CLI flags. If your
spec uses HTTP(S) `$ref`, run the merge step from a process that calls
`OpenApiSpecLoader::configure(..., allowRemoteRefs: true,
allowedRemoteRefHosts: ['specs.example.com'], ...)` first
(e.g. a Composer script), or pre-bundle remote refs offline.
---
Source: https://studio-design.github.io/gesso/migration/v2.md
# Prepare for Gesso 2.0
Gesso 2.0 will change the Composer package and PHP namespace so the technical
identity matches the project name. The final v1 minor provides a staged path:
move PHP imports first on v1.10, then change packages and the remaining
integration surfaces when v2 is available.
## Stage 1: move PHP imports on v1.10
Upgrade the existing package to v1.10 before changing namespaces:
```bash
composer require --dev studio-design/openapi-contract-testing:^1.10
```
Then replace the public namespace prefix in application and test code:
```diff
-use Studio\OpenApiContractTesting\OpenApiResponseValidator;
+use Studio\Gesso\OpenApiResponseValidator;
```
v1.10 lazily aliases every non-`@internal` public type from
`Studio\Gesso\...` to its v1 declaration. Classes, interfaces, traits, enums,
and attributes are covered. The allowlist is checked against the public API
inventory so a newly added public v1 type cannot silently miss its migration
alias.
Run the complete test suite after replacing imports. This stage isolates source
changes from the later Composer and integration changes.
Move scripts and CI to the unified command while still on v1.10:
```diff
-vendor/bin/openapi-contract doctor --spec=openapi.json
+vendor/bin/gesso doctor --spec=openapi.json
-vendor/bin/openapi-coverage-merge --spec-base-path=tests/fixtures/specs
+vendor/bin/gesso coverage:merge --spec-base-path=tests/fixtures/specs
```
V1.10 supports both forms with the same command options and exit-code
semantics. Migrating here avoids combining CLI changes with the later Composer
package replacement.
### What the v1 aliases do not change
The aliases cover PHP type references only. On v1.10, continue using the v1:
- Composer package `studio-design/openapi-contract-testing`;
- legacy commands `openapi-contract` and `openapi-coverage-merge` (the forward
`gesso` command is also available);
- Laravel configuration key and file `openapi-contract-testing`;
- Laravel service provider discovery and PHPUnit extension configuration;
- machine-readable tool identity and supported wire-format versions.
The declared v1 type remains canonical at runtime. Consequently,
`get_class()`, reflection, and newly serialized objects still contain
`Studio\OpenApiContractTesting\...` on v1.10. Do not rewrite persisted class
names or snapshots during this stage.
Internal types are intentionally not aliased. Code using an `@internal` type
must migrate away from that unsupported dependency rather than replace its
namespace.
## Stage 2: install v2
After `studio-design/gesso` v2 is published, replace the root Composer
requirement instead of treating the package as an in-place update.
To test a published beta, explicitly opt in to beta stability for this package:
```bash
composer remove --dev studio-design/openapi-contract-testing --no-update
composer require --dev "studio-design/gesso:^2.0@beta"
```
After v2.0.0 stable is published, use the normal stable constraint:
```bash
composer remove --dev studio-design/openapi-contract-testing --no-update
composer require --dev "studio-design/gesso:^2.0"
```
The `--no-update` step edits only the root requirement; the following
`composer require` resolves and installs the complete package switch. Do not
run them in separate deployed revisions. Commit the resulting `composer.json`
and `composer.lock` changes together with the namespace and integration changes
described below. Composer does not select beta versions under its default
`stable` policy unless the requirement opts in with a stability flag such as
`@beta`.
Before switching packages, upgrade the test environment to PHP 8.3 or later.
Gesso v2 requires PHP `^8.3` and supports PHPUnit `^12.0 || ^13.0`; it no longer
supports PHP 8.2 or PHPUnit 11. The supported combinations are PHPUnit 12 on PHP
8.3, 8.4, or 8.5 and PHPUnit 13 on PHP 8.4 or 8.5. Pest consumers must use Pest
4 with PHPUnit 12. These requirements are independent of the namespace rename,
but resolving them before the package switch keeps dependency failures separate
from source and configuration migration failures.
### Inject the response observation tracker
Direct construction of `OpenApiResponseValidator` requires a
`StrictRequiredTracker` as the first argument in v2. Create one instance for a
test run and reuse it across validators:
```diff
-$validator = new OpenApiResponseValidator(maxErrors: 20);
+$tracker = new StrictRequiredTracker();
+$validator = new OpenApiResponseValidator($tracker, maxErrors: 20);
```
Laravel, Symfony, and PSR-7 adapters resolve and inject the run-level tracker
automatically. This change removes the direct validator's service-locator
fallback; `maxErrors` and `skipResponseCodes` remain optional named arguments.
### Migrate Laravel configuration before changing packages
Laravel applications must rename the published configuration file while v1.10
is still installed, then change the package without booting the application in
between:
```bash
php artisan config:clear
mv config/openapi-contract-testing.php config/gesso.php
```
Update any direct lookups in application or test code:
```diff
-config('openapi-contract-testing.default_spec')
+config('gesso.default_spec')
```
Then replace the Composer requirement and boot the application on v2. Once the
application starts successfully, rebuild the configuration cache if the
deployment uses one:
```bash
php artisan config:cache
```
Do not publish the v2 configuration with `--force` over the renamed file; the
rename preserves project-specific values. V2 uses only the `gesso` key, the
`gesso` publish tag, and `config/gesso.php`. If it detects the legacy top-level
key (including in a stale configuration cache), its service provider stops with
an actionable migration error. It deliberately does not guess precedence when
both keys exist or silently accept the old key.
V2 declares `Studio\Gesso\` as its only product namespace and does not provide
a reverse `Studio\OpenApiContractTesting\` shim. It also exposes only the
unified `gesso` binary; the two legacy standalone binaries are removed. Complete
Stage 1 before the package switch.
### Update the PHPUnit extension FQCN
In the same change that replaces the Composer package, update the extension
class in `phpunit.xml` or `phpunit.xml.dist`:
```diff
-
+
```
Do not register both FQCNs. Gesso v2 does not contain the legacy namespace, and
the extension short name, XML structure, parameter names, defaults, accepted
values, path handling, and environment variables are unchanged. PHPUnit's
`--migrate-configuration` command migrates PHPUnit's configuration schema, not
this package-owned class name, so make this replacement explicitly.
### Update coverage JSON consumers
Gesso v2 coverage JSON uses `schema_version: 2` and identifies the writer as
`studio-design/gesso`:
```json
{
"schema_version": 2,
"tool": {
"name": "studio-design/gesso",
"version": "2.0.0"
}
}
```
Update validators, routing rules, snapshots, and dashboards that require
`schema_version: 1` or compare `tool.name` with the old package. The remaining
coverage fields and meanings stay compatible with schema version 1; the version
bump makes the documented fixed identity change visible instead of silently
passing it through existing consumers.
Laravel route parity JSON also advances to `schema_version: 2` because it adds
the `external_operations` result and summary count. Doctor JSON remains at
`schemaVersion: 1`, and the sidecar envelope and tracker-state versions remain
unchanged. Gesso v2's merge reader continues to accept the older sidecar
payloads documented in the
[versioning policy](../versioning.md#versioned-sidecar-compatibility), so a
branding-only migration does not make supported worker data unreadable.
### Update branded diagnostic log rules
Gesso v2 changes the two branded diagnostic prefixes from
`[openapi-contract-testing]` to `[Gesso]`. Update log routing or assertions for:
- the warning emitted when the optional `fakerphp/faker` package is missing;
- the Laravel contradictory-intent deprecation written to stderr.
Feature-oriented categories such as `[OpenAPI Coverage]`, `[OpenAPI Doctor]`,
`[OpenAPI Enum Drift]`, `[OpenAPI Strict Required]`, and
`[OpenAPI Strict Required per-call]` do not change. Keep existing rules for
those categories. V2 emits only the Gesso-branded form rather than duplicating
each warning under both names.
### Reload persisted resolved specifications
The eager resolver's internal discriminator provenance extension changes from
`x-studio-openapi-contract-testing-implicit-schema-name` to
`x-studio-gesso-implicit-schema-name`. If a tool persists the array returned by
the spec loader, discard that derived data and reload the original OpenAPI
Description after installing v2.
Do not rename the field in persisted resolved arrays. The resolver removes
both marker names from authored input to prevent a specification extension
from spoofing an implicit discriminator mapping, and it creates the Gesso
marker only after successfully resolving a direct local component-schema
reference. There is intentionally no legacy-marker read path or configuration
switch. Validation behavior for genuine implicit discriminator mappings is
otherwise unchanged.
See [ADR 0001](../adr/0001-gesso-v2-identity.md) for the complete identity
decision and [the namespace compatibility spike](v2-namespace-compatibility-spike.md)
for the tested behavior and identity limits of `class_alias()`.
---
Source: https://studio-design.github.io/gesso/migration/from-other-validators.md
# Migrating from other OpenAPI validators
Adopt this package incrementally: keep the existing validator for untouched tests, add the PHPUnit extension, then migrate one assertion at a time. A migrated assertion records coverage immediately.
## Spectator
Replace Spectator response assertions with `ValidatesOpenApiSchema`. Set `default_spec` once in Laravel config. Compare route coverage using `php artisan gesso:routes`, then enable `auto_assert` only after explicit assertions pass.
## league/openapi-psr7-validator
Keep the same PSR-7 messages and replace direct validator construction with `OpenApiPsr7Validator` or the `OpenApiAssertions` trait. The adapter adds PHPUnit failures and coverage reporting around the exchange.
## osteel/openapi-httpfoundation-testing
Keep the Symfony `Request` and `Response` values and switch to `Symfony\OpenApiAssertions`. Call `assertResponseMatchesOpenApiSchema()` or validate the last `KernelBrowser` exchange with `assertClientMatchesOpenApiSchema()`.
## kirschbaum-development/laravel-openapi-validator
Add `ValidatesOpenApiSchema` to the Laravel base test case and migrate explicit assertions first. If automatic validation is required, enable `auto_assert` and `auto_validate_request`, then use per-request opt-outs for intentionally out-of-contract tests.
See the [feature comparison](https://github.com/studio-design/gesso#why-this-library) for deliberately scoped, version-pinned differences. Do not remove the previous validator until the migrated suite reports the expected endpoint coverage.
---
Source: https://studio-design.github.io/gesso/setup.md
# Setup
This guide walks through end-to-end setup, including the configuration knobs, opt-out mechanisms, and the `auto_validate_request` family. For an at-a-glance quick start, choose a [tested quickstart](quickstarts/core.md).
When invoked through `vendor/bin/gesso`, the CLI uses the consuming project's
Composer autoloader, including for symlinked path dependencies. Direct execution
of `bin/gesso` from a library checkout uses that checkout's dependencies.
- [1. Provide your OpenAPI spec](#1-provide-your-openapi-spec)
- [2. Configure the PHPUnit extension](#2-configure-the-phpunit-extension)
- [Server base paths are not stripped automatically](#server-base-paths-are-not-stripped-automatically)
- [3. Use in tests](#3-use-in-tests)
- [With Laravel (recommended)](#with-laravel-recommended)
- [Framework-agnostic](#framework-agnostic)
- [Controlling the number of validation errors](#controlling-the-number-of-validation-errors)
- [Skipping responses by status code](#skipping-responses-by-status-code)
- [Auto-assert every response](#auto-assert-every-response)
- [Opting out with `#[SkipOpenApi]`](#opting-out-with-skipopenapi)
- [Per-request skip with `withoutValidation()`](#per-request-skip-with-withoutvalidation)
- [Per-request status-code skip with `skipResponseCode()`](#per-request-status-code-skip-with-skipresponsecode)
- [Auto-validate every request](#auto-validate-every-request)
- [Skip request validation when the response is a documented 4xx](#skip-request-validation-when-the-response-is-a-documented-4xx)
- [Auto-inject dummy credentials](#auto-inject-dummy-credentials)
- [HTTP `$ref` resolution (opt-in)](#http-ref-resolution-opt-in)
- [Remote spec sources (opt-in)](#remote-spec-sources-opt-in)
## 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](#http-ref-resolution-opt-in) below. If you prefer the legacy bundled-spec workflow, the loader still accepts the output of `npx @redocly/cli bundle --dereferenced` unchanged.
`spec_base_path` is also the filesystem trust boundary for entry documents and
local external references. Entry spec names may include nested directories but
cannot use `..`, an absolute path, or a symlink to escape that root. Gesso also
canonicalizes each local `$ref` target before reading it and rejects the target
when its final location escapes the directory.
When entry documents and shared schemas live in sibling directories, configure
their narrowest trusted common parent and include the entry subdirectory in the
spec name:
```xml
```
Here `openapi/bundled/front.yaml` may safely reference
`../shared/error.json`, while files outside `openapi/` remain inaccessible.
This follows the [OWASP path-traversal guidance](https://owasp.org/www-community/attacks/Path_Traversal)
to canonicalize filesystem input before validating it against an allowed
location.
## 2. Configure the PHPUnit extension
For a single file shared with Laravel, see [Shared configuration](configuration.md)
(available on `main`, not yet in 2.6.0). It replaces the duplicated settings
below with a root `gesso.php` and one `config` extension parameter.
For a plain PHPUnit or Symfony suite this is the whole configuration — no
`config/gesso.php` and no per-parameter XML:
```php
['base_path' => 'openapi/bundled', 'names' => ['front', 'admin'], 'strip_prefixes' => ['/api']],
'validation' => ['max_errors' => 10],
];
```
```xml
```
The existing v2 setup continues to work. Add the coverage extension to your `phpunit.xml`:
```xml
```
| Parameter | Required | Default | Description |
|---|---|---|---|
| `config` | No | Beside the selected PHPUnit XML file | Path to shared `gesso.php`; see [discovery and relative paths](configuration.md#discovery-and-relative-paths) (unreleased) |
| `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`). Required even when the spec declares the same base path in `servers` — see [Server base paths are not stripped automatically](#server-base-paths-are-not-stripped-automatically) |
| `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()`). Skipped on partial runs — see [Partial test runs](ci.md#partial-test-runs-filter-testsuite-path-args) |
| `junit_output` | No | — | File path to write JUnit XML coverage report (for CI dashboards — GitLab CI, Jenkins, SonarQube, Bitrise). A missing parent directory is created automatically at bootstrap (applies to `json_output` / `html_output` too); an empty value, a failed creation, or an unwritable parent directory is FATAL at bootstrap. See [Coverage output formats](ci.md#coverage-output-formats) |
| `json_output` | No | — | File path to write machine-readable JSON coverage report (custom dashboards, analytics, scripted gating). Schema: [`coverage-json-schema.md`](coverage-json-schema.md) |
| `html_output` | No | — | File path to write self-contained HTML coverage report (PR comments, CI artifact preview, offline review). See [`coverage-html-output.md`](coverage-html-output.md) |
| `console_output` | No | `default` | Console output mode: `default`, `all`, `uncovered_only`, or `active_only` (overridden by `GESSO_CONSOLE_OUTPUT` env var) |
| `validation_output` | No | `text` | Validation failure output format for all framework adapters: `text` or `json` (overridden by `GESSO_VALIDATION_FORMAT` env var). See [Validation JSON schema](validation-json-schema.md#selecting-json-failure-output-in-adapters) |
| `max_errors` | No | `20` | Maximum validation errors reported per request/response, `0` = unlimited. Process-wide default for validators constructed without an explicit `maxErrors` argument (the [framework-agnostic usage](#framework-agnostic)); the PSR-7 and Symfony hook defaults follow it, Laravel reads `config('gesso.max_errors')`. See [Controlling the number of validation errors](#controlling-the-number-of-validation-errors) |
| `skip_response_codes` | No | `5\d\d` | Comma-separated status-code regex patterns whose responses skip body validation (patterns must not contain commas). An explicitly empty value (`value=""`) means no skip patterns — 5xx bodies are validated too, matching Laravel's `[]`. Same process-wide default rule as `max_errors` |
| `skip_request_validation_response_codes` | No | — | Comma-separated status-code regex patterns (e.g. `422,400`) enabling the documented-4xx request downgrade: an invalid request that yields a matching, spec-documented status is reported Skipped instead of Failure. Same process-wide default rule as `max_errors` |
| `default_spec` | No | — | Spec name used when neither an `#[OpenApiSpec]` attribute nor an `openApiSpec()` override supplies one, including by the PSR-7 and Symfony adapters. Laravel reads `config('gesso.default_spec')` |
| `baseline_file` | No | — | Violation baseline file path (relative paths resolve from `getcwd()`). Running the suite with `GESSO_BASELINE_GENERATE=1` records every contract violation instead of failing and writes the sorted, versioned baseline here at run end (refused on partial runs; parallel workers stage their fingerprints in sidecars for `gesso coverage:merge --baseline-file` to union — see [Generating under parallel runners](baseline.md#generating-under-parallel-runners)). On normal runs the file is loaded (missing or malformed file is FATAL) and failures whose every violation is baselined are suppressed — only **new** violations fail. See the [baseline adoption guide](baseline.md) |
| `baseline_stale` | No | `note` | How baseline entries that no longer occur during a full run are reported: `off` (not evaluated), `note` (listed as removable), or `fail` (listed and the run exits non-zero). Requires `baseline_file`; skipped on partial runs. See [Ratcheting down](baseline.md#ratcheting-down) |
| `sidecar_dir` | No | `sys_get_temp_dir()/openapi-coverage-sidecars` | Directory exporting processes drop their JSON sidecar into — paratest workers, or CI shards that set `GESSO_SIDECAR_TOKEN`. Used only when the state is merged afterwards; see [Parallel test runners](parallel.md) and [Sharded CI jobs](parallel.md#sharded-ci-jobs) |
| `min_endpoint_coverage` | No | — | Minimum fully covered endpoint percentage (`0`–`100`). See [Coverage threshold gate](coverage.md#coverage-threshold-gate) |
| `min_response_coverage` | No | — | Minimum validated response-row percentage (`0`–`100`) |
| `min_sdk_exercise_coverage` | No | — | Minimum eligible response-schema percentage attempted by SDK decoder callbacks (`0`–`100`) |
| `min_coverage_strict` | No | `false` | When true, any configured coverage-threshold miss exits non-zero; otherwise misses warn only |
| `strict_additional_properties` | No | `off` | Undocumented response-property gate: `off`, `warn`, or `fail`. See [`strict-additional-properties.md`](strict-additional-properties.md) |
| `strict_additional_properties_per_call` | No | `off` | Immediate undocumented response-property warning: `off` or `warn`. Pair with PHPUnit `failOnWarning="true"` for per-test failure. |
| `default_testsuite_as_full` | No | `false` | Opt-in. When `true` and PHPUnit's `includeTestSuites` resolves exactly to the configured `defaultTestSuite`, treat the run as full instead of partial (so `strict_required` and coverage outputs aren't suppressed). See [default_testsuite_as_full opt-in](ci.md#default_testsuite_as_full-opt-in) for trade-offs |
| `enforce_discriminator` | No | `true` | When `true` (default), `discriminator` + `mapping` is enforced via `if`/`then` lowering so a body that lies about its type fails. Set to `false` (or `0` / `no`) to strip `discriminator` without enforcing (no warning either). See [Schema features → discriminator](supported-features.md#schema-features) |
| `acknowledged_unvalidatable_schemes` | No | — | Comma-separated `components.securitySchemes` names acknowledged as unvalidatable. Suppresses the one-shot `[security]` silent-pass warning for exactly those schemes; every other unvalidatable scheme keeps warning. See [Acknowledging an unvalidatable security scheme](#acknowledging-an-unvalidatable-security-scheme) |
*Not required if you call `OpenApiSpecLoader::configure()` manually.
### Server base paths are not stripped automatically
`servers[].url` **does not participate in request-path matching.** A spec that
declares `servers: [{ url: "/api" }]` alongside a `/pets` path still needs
`strip_prefixes` set to `/api` for a request to `/api/pets` to match.
The two values look interchangeable and are not: `servers[].url` says where the
published API is served, while `strip_prefixes` says where the application under
test mounts its routes. They diverge whenever a gateway terminates the prefix,
the test harness boots the app at the root, or the spec's `servers` carries a
hostname and no path. `servers` is also overridable per path item and per
operation, and reading an override requires having already matched the path.
A templated `url` resolves through its required variable `default`s, which say
what is served absent an alternate value — not what your test deployment uses.
[ADR 0006](adr/0006-server-base-paths-and-request-path-matching.md) records the
full reasoning.
A failed match names the prefix when the spec declares one that would have
worked, so the value to configure is in the failure output rather than left to
be derived:
```text
No matching path found in 'petstore' spec for GET /api/pets
servers[0].url declares base path '/api'; '/pets' matches after removing it.
Gesso does not strip server base paths automatically — add '/api' to strip_prefixes.
closest spec paths:
- GET /pets
```
## 3. Use in tests
### With Laravel (recommended)
Publish the config file:
```bash
php artisan vendor:publish --tag=gesso
```
This creates `config/gesso.php`:
```php
return [
'default_spec' => '', // e.g., 'front'
// Used by Laravel Artisan commands such as gesso:routes.
'spec_base_path' => base_path('openapi'),
// Keep aligned with the PHPUnit extension's strip_prefixes parameter —
// or move both to the root gesso.php so nothing needs aligning.
'strip_prefixes' => [],
// Maximum number of validation errors to report per response.
// 0 = unlimited (reports all errors).
'max_errors' => 20,
// Enforce `discriminator` + `mapping` by lowering it into if/then
// conditionals so a body that lies about its type fails (default true).
// Set false to strip discriminator without enforcing (no warning either).
'enforce_discriminator' => true,
// 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:
The Laravel config and PHPUnit extension are separate entry points. Configure
the same spec directory and prefixes in both so `gesso:routes` and runtime
validation compare the same paths — or move both to the root `gesso.php`
([Shared configuration](configuration.md)) so nothing needs aligning. See [Laravel route parity](laravel-route-parity.md)
for filters, JSON output, and CI exit codes.
```php
use Studio\Gesso\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:
```php
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\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:
```php
#[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('gesso.default_spec')` | Project-wide default set once in `config/gesso.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):
```php
use Studio\Gesso\Attribute\OpenApiSpec;
// config/gesso.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/gesso.php.
```
> **Note:** `openApiSpec()` remains the original extension hook and is fully backward-compatible — overriding it works exactly as before.
### With Symfony
For Symfony projects, mix the `OpenApiAssertions` trait into a `WebTestCase` (or any PHPUnit test). It validates HttpFoundation `Request` / `Response` objects directly against the spec — no PSR-7 conversion required — and records endpoint coverage the same way the Laravel adapter does.
```php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Symfony\OpenApiAssertions;
#[OpenApiSpec('front')]
final class PetsTest extends WebTestCase
{
use OpenApiAssertions;
public function test_list_pets(): void
{
$client = static::createClient();
$client->request('GET', '/api/v1/pets');
// Validates both the request and the response of the last client call.
$this->assertClientMatchesOpenApiSchema($client);
}
}
```
You can also pass HttpFoundation objects directly — useful when you are not driving the test through the kernel browser:
```php
// Response only (the Request supplies the HTTP method and path):
$this->assertResponseMatchesOpenApiSchema($request, $response);
// Request only (optionally pass the response status for the documented-4xx downgrade):
$this->assertRequestMatchesOpenApiSchema($request, $response->getStatusCode());
```
Spec resolution uses the same `#[OpenApiSpec]` attribute / `openApiSpec()` chain as the framework-agnostic usage below. Without either, set `spec.default` in `gesso.php` (or the `default_spec` extension parameter); the attribute and an `openApiSpec()` override still win. The `max_errors` and skip-code hooks follow the same shared settings unless overridden. Spec files are still discovered through the [PHPUnit extension](#2-configure-the-phpunit-extension) (`spec_base_path`) or a direct `OpenApiSpecLoader::configure()` call.
> **Requires `symfony/http-foundation`.** Symfony projects already depend on it; it is listed under `suggest` so standalone installs aren't forced to pull it in.
### Framework-agnostic
You can use the `#[OpenApiSpec]` attribute with the `OpenApiSpecResolver` trait in any PHPUnit test.
Validators constructed without explicit arguments pick up the extension's
`max_errors`, `skip_response_codes`, and `skip_request_validation_response_codes`
parameters, and when no attribute or `openApiSpec()` override names a spec,
`resolveOpenApiSpec()` falls back to the `default_spec` parameter — so a plain
suite can configure the validation policy entirely from `phpunit.xml`:
```php
use Studio\Gesso\Attribute\OpenApiSpec;
use Studio\Gesso\Spec\OpenApiSpecResolver;
use Studio\Gesso\OpenApiResponseValidator;
use Studio\Gesso\Validation\Strict\StrictRequiredTracker;
#[OpenApiSpec('front')]
class GetPetsTest extends TestCase
{
use OpenApiSpecResolver;
public function test_list_pets(): void
{
$specName = $this->resolveOpenApiSpec(); // 'front'
$validator = new OpenApiResponseValidator(new StrictRequiredTracker());
$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:
```php
use Studio\Gesso\OpenApiResponseValidator;
use Studio\Gesso\Spec\OpenApiSpecLoader;
use Studio\Gesso\Validation\Strict\StrictRequiredTracker;
// Configure once (e.g., in bootstrap)
OpenApiSpecLoader::configure(__DIR__ . '/openapi/bundled', ['/api']);
// In your test
$validator = new OpenApiResponseValidator(new StrictRequiredTracker());
$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:
```php
$tracker = new StrictRequiredTracker();
// Report up to 5 errors
$validator = new OpenApiResponseValidator($tracker, maxErrors: 5);
// Report all errors (unlimited)
$validator = new OpenApiResponseValidator($tracker, maxErrors: 0);
// Stop at first error
$validator = new OpenApiResponseValidator($tracker, maxErrors: 1);
```
For Laravel, set the `max_errors` key in `config/gesso.php`. For plain PHPUnit,
PSR-7, and Symfony suites, `validation.max_errors` in `gesso.php` (or the
extension's `max_errors` parameter) sets the process-wide default that
validators constructed without an explicit `maxErrors` argument, and the
adapters' `openApiMaxErrors()` hooks, pick up
— see the [parameter table](#2-configure-the-phpunit-extension).
## Reproduction commands in failure output
Every adapter assertion failure (Laravel, Symfony, Pest, PSR-7) ends with a
one-line curl command that reproduces the validated request:
```text
OpenAPI schema validation failed for POST /v1/pets (spec: front):
[/name] The data (integer) must match the type: string
Reproduce: curl -X POST 'https://api.example.test/v1/pets' -H 'Authorization: ' -H 'Content-Type: application/json' --data '{"name":42}'
```
The PSR-7 exchange assertion prefixes each error line with its `[request]` /
`[response]` side label and ends with one `Reproduce:` line for the whole
exchange. In the JSON failure output mode the same command is carried in the
document's `reproduce_command` field instead of a trailing line — see
[validation-json-schema.md](validation-json-schema.md).
Header and query values that look sensitive are redacted before the command
is rendered:
- `Authorization`, `Proxy-Authorization`, and `Cookie` header values, plus
any header whose name contains `api-key` / `api_key` / `apikey`, `token`,
or `secret` (case-insensitive), become ``.
- Query-string values whose parameter name matches the same pattern are
redacted too (OpenAPI supports `apiKey` security in query parameters).
- The request body is rendered only for JSON content types and is **not**
redacted. A body can itself carry credentials (a login password, a token
refresh payload), so treat the failure output as sensitive as a whole —
the redaction keeps header and query secrets out of CI logs, it does not
make the output safe to share.
Redaction in adapter failure output cannot be disabled. Only the fuzzing
API's `ExploredCase::curlSnippet(redactSensitiveHeaders: false)` opts out,
for local debugging ([fuzzing.md](fuzzing.md)). The command is rendered only
when an assertion actually fails, so a passing PSR-7 assertion never
re-reads the body stream for output — validation itself still decodes
seekable streams (and restores the cursor) either way.
## 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/gesso.php`:
```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`:
```php
$validator = new OpenApiResponseValidator(
new StrictRequiredTracker(),
skipResponseCodes: ['5\d\d'],
);
```
Notes:
- Patterns are regex strings **without** `/` delimiters or `^$` anchors; they are anchored automatically, so `5\d\d` matches exactly `500`–`599` (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. `OpenApiValidationResult::isSkipped()` returns `true` for status-code skips **and** for responses/requests whose body could not be schema-validated because the spec declared it under a non-JSON content type (with a `schema` the JSON Schema engine cannot evaluate). A schema-less `204` and a non-JSON content type with no `schema` have nothing to validate and 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:
```php
// config/gesso.php
return [
'default_spec' => 'front',
'auto_assert' => true,
];
```
```php
use Studio\Gesso\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:
```php
use Studio\Gesso\Attribute\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:
```php
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:
```php
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:
```php
// config/gesso.php
return [
'default_spec' => 'front',
'auto_validate_request' => true,
'auto_inject_dummy_credentials' => 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.
## Skip request validation when the response is a documented 4xx
Tests that intentionally send invalid input to verify the impl returns a documented 422 / 400 would otherwise double-fail under `auto_validate_request`: the request is genuinely spec-invalid (that's the point), but the test only cares about asserting the 4xx response. By default, the library downgrades the request validation result from Failure to Skipped when the response status matches `skip_request_validation_response_codes` AND the spec documents that status for the operation. Default `['422', '400']`:
```php
return [
// default — downgrade documented 422 / 400 responses, keep undocumented 4xx loud
'skip_request_validation_response_codes' => ['422', '400'],
// strict — every spec violation surfaces, even for documented 4xx
'skip_request_validation_response_codes' => [],
// wider net — downgrade every documented 4xx
'skip_request_validation_response_codes' => ['4\d\d'],
];
```
Notes:
- **Documented-only**: the downgrade only applies when the response status is in the matched operation's `responses` map (exact match, range key like `4XX`, or `default`). An undocumented 4xx still fails — that's a real spec gap.
- **Failure-only**: a request that passes validation cleanly stays Success even if the response is a documented 4xx (legitimate business-logic 422 on a perfectly-shaped payload is not demoted).
- **Coverage**: downgraded requests are recorded with the skip reason so the coverage report distinguishes "request was validated cleanly" from "request was downgraded because of a documented 4xx".
## Auto-inject dummy credentials
When `auto_validate_request=true`, endpoints whose spec declares an enforceable security scheme (`http` + `bearer`, or `apiKey` in header / cookie / query) fail the security check unless the test supplies the credential. For test suites that authenticate via `actingAs()` or middleware bypass — and therefore never set real credentials — set `auto_inject_dummy_credentials=true` to auto-inject fixed dummy values into the validator's view of the request:
```php
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.
- **Injected kinds**: `Authorization: Bearer test-token` for `http` + `bearer`, and a fixed `test-api-key` value for every `apiKey` scheme (header / cookie / query) the operation declares. `oauth2`, `openIdConnect`, `mutualTLS`, and non-bearer `http` schemes are silent-passed by the validator and therefore not injected.
- **Never overrides non-empty user values**: if the test already set a non-empty value for the credential slot — an `Authorization` header in any case, the named cookie, or the named query / header parameter — the user's value wins, even when it is deliberately malformed. Empty-string values count as absent and are injected over, matching the validator's own missing-credential definition.
- **Requires `auto_validate_request=true`** — the inject is a sub-feature of request validation. Setting the inject flag alone has no effect.
- **Deprecated predecessor**: `auto_inject_dummy_bearer` (bearer-only) still works, but enabling it emits a one-shot `E_USER_DEPRECATED` notice and the key is removed in Gesso 3.0. Its exact bearer-only behavior is available on `main` as `laravel.auto_inject_dummy_credentials = 'bearer'` in the shared `gesso.php` (unreleased; not accepted in 2.6.0). `true` also injects `apiKey` schemes — see [Deprecations in `UPGRADING.md`](https://github.com/studio-design/gesso/blob/main/UPGRADING.md#deprecations) for the migration options. When both flags are set, `auto_inject_dummy_credentials` wins.
## Acknowledging an unvalidatable security scheme
The validator cannot enforce `oauth2`, `openIdConnect`, `mutualTLS`, or `http` schemes other than `bearer` (basic, digest, …). Requests secured only by such a scheme silently pass the security check, and the first encounter per scheme emits a one-shot `E_USER_WARNING` so the silent pass does not stay invisible (see the [warning channel](supported-features.md#diagnostic-channels-e_user_warning-and-e_user_deprecated)).
Under Laravel that warning is converted into a test failure by the framework's error handler — and because it is one-shot, *which* test fails depends on execution order. When the scheme genuinely cannot be avoided (e.g. RFC 7009 / RFC 7662 mandate HTTP Basic client authentication for token revocation / introspection endpoints) and the authentication is covered by a dedicated test, acknowledge the scheme **by name** instead of filtering `E_USER_WARNING` globally:
```php
// config/gesso.php (Laravel)
return [
'auto_validate_request' => true,
// components.securitySchemes keys covered by a dedicated auth test.
'acknowledged_unvalidatable_schemes' => ['ClientBasicAuth'],
];
```
```xml
```
Notes:
- **Scheme-scoped, not global**: only the listed scheme names stop warning. Any other unvalidatable scheme — including one added to the spec later — still warns.
- **The list cannot rot**: acknowledging a name that is not defined in `components.securitySchemes`, or one the validator *can* enforce (`http` + `bearer`, `apiKey`), itself emits a one-shot warning. These rot warnings carry the `[Gesso]` prefix (not `[security]`) — route them accordingly in a prefix-matching error handler.
- **Validation behavior is unchanged**: acknowledged schemes were already silently passed; the acknowledgement only silences the warning.
- `gesso doctor` reflects the acknowledgement via `--acknowledge-unvalidatable-scheme` — see [the doctor guide](doctor.md#acknowledged-unvalidatable-security-schemes).
## 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`:
```bash
# 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
```
```php
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Studio\Gesso\Spec\OpenApiSpecLoader;
OpenApiSpecLoader::configure(
basePath: 'openapi/',
httpClient: new Client(), // PSR-18 ClientInterface
requestFactory: new HttpFactory(), // PSR-17 RequestFactoryInterface
allowRemoteRefs: true,
allowedRemoteRefHosts: ['specs.example.com'],
maxRemoteRefBytes: 10_485_760, // optional; 10 MiB default per document
);
```
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.)
Remote-reference diagnostics remove URL userinfo and query values before they
reach exception messages or CI logs. Configure authentication on the HTTP
client instead of embedding credentials in a `$ref` URL. Raw PSR-18 transport
and response-body stream exceptions are not chained into the public exception
because a nested cause may contain unsanitized request data; the sanitized
top-level message is retained.
The original `$ref` argument is also replaced with its diagnostic-safe form
before network operations, so exception traces remain redacted even when
`zend.exception_ignore_args=Off`.
Remote access requires a non-empty `allowedRemoteRefHosts` list. Matching is
case-insensitive against the URL's exact host; schemes, ports, paths, and
userinfo do not belong in the list. Every nested remote `$ref` is checked, so
a document served by an allowed host cannot redirect resolution to another
host through an absolute `$ref`. Configure the injected PSR-18 client not to
follow redirects and use the canonical URL instead: a client-side redirect
would otherwise happen below Gesso's host-policy boundary.
This follows OWASP's SSRF guidance to prefer an allowlist and disable redirect
following. DNS and network-layer controls remain the application's
responsibility because PSR-18 does not expose connection-level address policy.
[See the OWASP SSRF Prevention Cheat Sheet.](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
Each remote document is limited to 10 MiB by default. Gesso checks a valid
`Content-Length`, the PSR-7 stream's known size, and the bytes actually read, so
a missing or false size header cannot bypass the limit. Set
`maxRemoteRefBytes` to a positive integer only when a trusted contract is
larger. A PSR-18 client may buffer a response before returning its PSR-7 stream;
also configure client-level timeouts and transfer limits where the selected
implementation supports them. This bounds the transport as well as Gesso's
parser boundary. See the [PSR-7 stream rationale](https://www.php-fig.org/psr/psr-7/#13-streams).
Misconfiguration is caught early:
| Setup | Result |
| --- | --- |
| `allowRemoteRefs: true` without `$httpClient` / `$requestFactory` | `InvalidArgumentException` at `configure()` |
| `allowRemoteRefs: true` without `allowedRemoteRefHosts` | `InvalidArgumentException` at `configure()` |
| HTTP(S) `$ref` targets an unlisted host | `InvalidOpenApiSpecException` with reason `RemoteRefHostDisallowed`; no request is sent |
| `$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 — keep redirects disabled and use the canonical URL |
| Remote response exceeds `maxRemoteRefBytes` | `RemoteRefFetchFailed` before parsing; raise the limit only for a trusted contract |
| `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`.
## Remote spec sources (opt-in)
Normally a named spec resolves to a file under `basePath`. When the contract
lives in a central registry or a separate spec repository (spec-first orgs,
API gateways), `remoteSpecs` lets a name resolve to an HTTP(S) entry document
instead — including a private GitHub repository — without vendoring the file
into every service repo:
```php
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use Studio\Gesso\Spec\OpenApiSpecLoader;
use Studio\Gesso\Spec\RemoteSpecSource;
OpenApiSpecLoader::configure(
basePath: 'openapi/', // still used for filesystem specs
httpClient: new Client(['allow_redirects' => false]),
requestFactory: new HttpFactory(),
allowRemoteRefs: true,
allowedRemoteRefHosts: ['raw.githubusercontent.com'],
remoteSpecs: [
// Private GitHub repo via a fine-grained PAT with contents:read.
'front' => new RemoteSpecSource(
'https://raw.githubusercontent.com/acme/api-specs/main/bundled/front.json',
authorizationEnv: 'GESSO_SPEC_TOKEN',
),
// Unauthenticated internal registry: a plain URL works too.
'admin' => 'https://specs.internal.example.com/admin/openapi.yaml',
],
);
```
```bash
# GitHub raw content accepts a PAT as a bearer token.
export GESSO_SPEC_TOKEN='Bearer github_pat_…'
```
After that, `front` and `admin` behave like any other spec name — in
`specs=` for coverage, in `#[OpenApiSpec(...)]` attributes, and in the direct
validators. Call `configure()` from your PHPUnit bootstrap file and omit the
extension's `spec_base_path` parameter (the extension would otherwise
re-configure the loader without your HTTP client); keep `specs=` so remote
documents are eagerly fetched and structurally validated at bootstrap.
Rules and behavior:
- **Same opt-in as HTTP `$ref`s.** `remoteSpecs` requires `allowRemoteRefs:
true`, the PSR-18/PSR-17 pair, and every URL's host in
`allowedRemoteRefHosts` — all enforced at `configure()` time. Redirect
rejection, the response-size limit, format detection, and URL redaction
apply exactly as described in the previous section.
- **`authorizationEnv` is provider-agnostic.** The environment variable's
value is sent verbatim as the `Authorization` header (`Bearer …`,
`token …`, `Basic …` — whatever the registry expects). A missing or empty
variable fails loudly with reason `RemoteSpecAuthEnvMissing` before any
request is sent. The value itself never appears in diagnostics.
- **Credentials are origin-scoped.** The header is sent to the entry
document's origin only — same scheme, host, and effective port (RFC 6454).
Relative `$ref`s (which resolve against the entry URL per RFC 3986) and
same-origin absolute `$ref`s carry it; anything else never does: not a
cross-host `$ref` (even to another allowlisted host), not another port on
the same host, and not an `http://` downgrade of an `https://` entry
document.
- **A mapped name never touches the filesystem.** If `front` is in
`remoteSpecs`, `openapi/front.json` is ignored for that name.
- **Fetched once per process.** The resolved document is cached under its
spec name like a local spec; parallel workers fetch once each.
For CI predictability, pin the document to the exact bytes you reviewed:
```php
'front' => new RemoteSpecSource(
'https://raw.githubusercontent.com/acme/api-specs/main/bundled/front.json',
authorizationEnv: 'GESSO_SPEC_TOKEN',
expectedSha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
),
```
A mismatch fails with reason `RemoteSpecHashMismatch` (showing expected and
actual digests) before the document is parsed, so an upstream edit — or a
compromised registry — cannot silently change what your tests enforce.
Compute the pin with `curl -s … | shasum -a 256` and commit it; update it
deliberately when the upstream contract changes. If you prefer freshness over
pinning, point the URL at an immutable ref (a commit SHA instead of a branch)
or add ETag caching middleware to the injected PSR-18 client — Gesso
deliberately performs plain conditional-free GETs and leaves transport
caching to the client.
---
Source: https://studio-design.github.io/gesso/configuration.md
# Shared PHPUnit and Laravel configuration
> Available on `main`; not included in the published 2.6.0 release.
Put shared settings in `gesso.php` at the application root. The PHPUnit
extension and Laravel service provider read the same file, so spec paths,
request prefixes, and validation policy need only be written once.
The runnable [Scramble example](recipes/scramble.md#run-the-example) exercises
this setup with real HTTP tests and Artisan commands.
## Minimal setup
```php
[
'base_path' => 'openapi',
'default' => 'api',
'names' => ['api'],
'strip_prefixes' => ['/api'],
],
'coverage' => ['report_output' => ['json' => 'build/coverage.json']],
];
```
Register the extension in `phpunit.xml`:
```xml
```
Laravel discovers its application-root `gesso.php` automatically. Keep the
usual `ValidatesOpenApiSchema` trait and assertions in your tests. You do not
need to publish `config/gesso.php` for this setup.
`spec.names` lists the specs PHPUnit must load and include in coverage;
`spec.default` selects the fallback for assertions. Set both as above for a
single-spec project. `spec.strip_prefixes` controls matching, independently
of the `--request-prefix` option used to generate test requests.
## Discovery and relative paths
- PHPUnit resolves the `config` parameter relative to the selected PHPUnit XML
file. Without the parameter, it looks for `gesso.php` beside that XML file.
If no XML file is selected, it uses the working directory.
- Laravel looks in `Application::basePath()`, independently of the working
directory. A custom PHPUnit config path should point to that same root file
when sharing settings with Laravel.
- Paths **inside** `gesso.php` resolve against that file's directory: spec base
path, report outputs, sidecar directory, and baseline files.
- An explicitly selected missing file, unknown key, invalid value, or PHP
evaluation error aborts PHPUnit with exit 1 even without `failOnWarning`.
An absent conventional file preserves existing behavior.
For example, `cd tests && ../vendor/bin/phpunit -c ../phpunit.xml` reads the
same specs and writes reports to the same paths as running from the root.
Use plain PHP values and `__DIR__` if needed; Laravel-only helpers such as
`base_path()` and `env()` may be unavailable when PHPUnit reads the file.
## Compatibility and precedence
This is an additive v2 bridge. Existing XML parameters and Laravel config keys
continue to work. Only keys explicitly declared in `gesso.php` contribute
values; omitted or nullable settings left at `null` retain existing consumer
defaults. In particular, an omitted `spec.names` retains PHPUnit's `front`
default; an omitted `spec.base_path` retains the programmatic loader setup.
In that case, an explicit `spec.strip_prefixes` still replaces the loader's
prefixes without changing its base path, enum path, remote-reference settings,
or cached specs. An empty list clears the prefixes; omitting the setting
preserves the prefixes configured in bootstrap.
- PHPUnit: existing format/console environment overrides, then explicit XML
parameters, then declared shared settings, then existing defaults.
- Laravel: existing `config('gesso.…')` values, then declared shared settings,
then package defaults. Arrays are replaced as whole values, not concatenated.
- Explicit per-test and per-call overrides keep their existing priority.
To migrate, copy values into the root file, remove their matching XML
parameters, and remove the copied keys from `config/gesso.php`. Once all values
have moved, delete `config/gesso.php` and clear any Laravel configuration cache.
Leaving copies behind means each adapter's old values still win, which can
keep the two adapters inconsistent. `vendor:publish --tag=gesso` still publishes
the existing v2 file; avoid republishing it after migration.
Laravel's `config:cache` captures the resolved shared settings in its normal
snapshot. While cached, the provider does not evaluate `gesso.php` again.
Run `php artisan config:clear` after changing shared settings before testing;
PHPUnit itself always reads the source file. This follows Laravel's
[configuration cache lifecycle](https://laravel.com/docs/12.x/configuration#configuration-caching).
Booleans in the shared file accept PHP booleans and the strings `true/false`,
`1/0`, `yes/no`, `on/off`, or an empty string (false). Invalid strings fail.
Lists stay arrays, including patterns containing commas:
```php
'validation' => [
'max_errors' => 10,
'enforce_discriminator' => 'off',
'skip_response_codes' => ['5[0-9]{2,2}'],
'skip_request_validation_response_codes' => [],
],
```
For dummy request credentials, use `laravel.auto_inject_dummy_credentials`:
`false` disables injection, `true` fills missing Bearer and API-key credentials,
and `'bearer'` fills only missing HTTP Bearer credentials. All modes require
`laravel.auto_validate_request => true`. Injection changes only the validator's
view, never the dispatched request, and never replaces a populated credential.
A missing API key still fails in `'bearer'` mode, including operations that
require both Bearer and an API key. The named mode does not use the deprecated
`auto_inject_dummy_bearer` setting or emit its deprecation.
## Settings connected in this release
| Shared setting | PHPUnit parameter | Laravel config key (`gesso.` prefix) |
| --- | --- | --- |
| `spec.base_path` | `spec_base_path` | `spec_base_path` |
| `spec.default` | `default_spec` | `default_spec` |
| `spec.names` | `specs` | — |
| `spec.strip_prefixes` | `strip_prefixes` | `strip_prefixes` |
| `validation.max_errors` | `max_errors` | `max_errors` |
| `validation.enforce_discriminator` | `enforce_discriminator` | `enforce_discriminator` |
| `validation.acknowledged_unvalidatable_schemes` | `acknowledged_unvalidatable_schemes` | `acknowledged_unvalidatable_schemes` |
| `validation.skip_response_codes` | `skip_response_codes` | `skip_response_codes` |
| `validation.skip_request_validation_response_codes` | `skip_request_validation_response_codes` | `skip_request_validation_response_codes` |
| `validation.format` | `validation_output` | — (run-wide PHPUnit selection) |
| `strict.required.run` / `strict.required.per_call` | `strict_required` / `strict_required_per_call` | — |
| `strict.additional_properties.run` / `strict.additional_properties.per_call` | `strict_additional_properties` / `strict_additional_properties_per_call` | — |
| `coverage.min_coverage.endpoint` / `.response` / `.sdk_exercise` | `min_endpoint_coverage` / `min_response_coverage` / `min_sdk_exercise_coverage` | — |
| `coverage.min_coverage.strict` | `min_coverage_strict` | — |
| `coverage.report_output.markdown` / `.json` / `.junit` / `.html` | `output_file` / `json_output` / `junit_output` / `html_output` | — |
| `coverage.console_report` | `console_output` | — |
| `coverage.sidecar_dir` | `sidecar_dir` | — |
| `baseline.violations` / `baseline.coverage` | `baseline_file` / `coverage_baseline_file` | — |
| `baseline_stale.violations` / `baseline_stale.coverage` | `baseline_stale` / `coverage_baseline_stale` | — |
| `enum_drift.enabled` / `.scan_namespaces` / `.fail_on_drift` | `enum_drift_enabled` / `enum_drift_scan_namespaces` / `enum_drift_fail_on_drift` | — |
| `phpunit.default_testsuite_as_full` | `default_testsuite_as_full` | — |
| `laravel.auto_assert` | — | `auto_assert` |
| `laravel.auto_validate_request` | — | `auto_validate_request` |
| `laravel.auto_inject_dummy_credentials` | — | `auto_inject_dummy_credentials` |
| `laravel.route_parity.external_operation_ids` / `.external_openapi_paths` | — | `route_parity.external_operation_ids` / `.external_openapi_paths` |
Enum-valued keys are matched after trimming and lower-casing, exactly like the
equivalent PHPUnit parameters; the canonical lower-case value is what the
surfaces receive.
Use the existing [setup](setup.md) and [coverage](coverage.md) guides for each
setting's behavior. `enum_spec_base_path` remains an XML-only v2 compatibility
setting; it has no shared successor.
This step connects PHPUnit and Laravel, including `gesso:routes` and
`gesso:stubs`. Standalone `vendor/bin/gesso` commands still require their existing
flags. The PSR-7 and Symfony policy hooks (`openApiSpec()`, `openApiMaxErrors()`,
`openApiSkipResponseCodes()`, `openApiSkipRequestValidationResponseCodes()`)
default to the shared `spec.default` and `validation.*` values; an override
still wins. This does not yet migrate every surface or implement the complete
[v3 configuration plan](adr/0005-v3-configuration-and-cli-naming.md).
Legacy-input deprecations and removals are separate follow-up work.
---
Source: https://studio-design.github.io/gesso/coverage.md
# Coverage Report
After running tests, the PHPUnit extension prints a coverage report. The output format is controlled by the `console_output` parameter (or `GESSO_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 |
Specification extensions (`x-` keys) on a Responses Object are not responses and are excluded from the declared set.
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`).
SDK response exploration is reported separately as **SDK exercise coverage**.
It measures whether eligible JSON response schemas were handed to a generated
SDK decoder; it does not change HTTP validation coverage. A response can be
covered by an HTTP contract assertion but remain unexercised by an SDK, or vice
versa.
- [Console modes](#console-modes)
- [Coverage threshold gate](#coverage-threshold-gate)
- Output formats — see [`ci.md`](ci.md#coverage-output-formats), [`coverage-html-output.md`](coverage-html-output.md), [`coverage-json-schema.md`](coverage-json-schema.md)
- Parallel-runner merge — see [`parallel.md`](parallel.md)
## Console modes
### `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
SDK responses: 20/24 exercised (83.3%), 4 unexercised
--------------------------------------------------
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
```
To turn that list into pre-written tests, feed the same run's JSON output to
[`gesso stubs`](stubs.md).
### `active_only` mode
Useful for the local TDD loop with a multi-spec setup (e.g. `specs="front,store,admin"`). Specs that no test in this run touched are collapsed to a single line, so a focused single-test run no longer has to scroll past hundreds of `✗ uncovered` rows for unrelated specs. Specs with at least one validated, skipped, or request-only observation render the same one-line-per-endpoint view as `default`:
```
[front] no test activity (373 endpoints, 894 responses in spec)
[store] no test activity (148 endpoints, 312 responses in spec)
[admin] endpoints: 1/72 fully covered (1.4%), 0 partial, 71 uncovered
responses: 1/172 covered (0.6%), 0 skipped, 171 uncovered
--------------------------------------------------
Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type
✓ GET /v2/admin/early_accesses (1/1 responses)
✗ POST /v2/admin/early_accesses (0/2 responses)
...
```
You can set the mode via `phpunit.xml`:
```xml
```
Or via environment variable (takes priority over `phpunit.xml`):
```bash
GESSO_CONSOLE_OUTPUT=uncovered_only vendor/bin/phpunit
```
## Coverage threshold gate
Optional CI gate that fails the run when contract or SDK exercise coverage drops below a configured percentage — the contract-testing analogue of PHPUnit's own `--coverage-threshold`. All metrics are aggregated across every spec listed in `specs=`:
- `min_endpoint_coverage` — percentage of endpoints with **all** declared `(status, content-type)` pairs validated.
- `min_response_coverage` — percentage of `(method, path, status, content-type)` rows validated (the same rate the report calls "responses covered").
- `min_sdk_exercise_coverage` — percentage of eligible JSON response schemas for which an SDK decoder callback was attempted.
The SDK denominator includes resolved JSON-compatible response media types
with a schema. It excludes no-content responses, non-JSON media types, missing
or non-JSON schemas, and OpenAPI 3.2 `itemSchema` streaming responses. A
configured SDK gate fails as unevaluable when that denominator is empty.
Default is **warn-only**: a miss prints `[OpenAPI Coverage] WARN: …` to stderr but the run exits 0. Flip `min_coverage_strict=true` to make a miss fail-fast with exit 1.
> To stop coverage from *regressing*, prefer the
> [coverage baseline](coverage-baseline.md): it gates the set of uncovered
> responses instead of a percentage, so there is no threshold to keep raising
> by hand, documenting a new response cannot fail an unrelated PR, and a
> failure names the offending rows. To gate only what the pull request
> actually touched, add the
> [spec patch coverage gate](coverage-gate.md).
```xml
```
Failure looks like:
```
[OpenAPI Coverage] FAIL: endpoint coverage 67.4% < threshold 80%.
response coverage 71.2% (>= 60%, ok).
SDK exercise coverage 83.3% < threshold 100%.
```
Out-of-range or non-numeric values produce a `WARNING` to stderr and skip that gate (rather than silently treating the misconfiguration as `0%`).
On a partial run (`--filter`, `--testsuite`, path args, …) the gate is skipped with a one-line NOTE instead of evaluating — a subset cannot prove a suite-wide coverage rate. See [Partial test runs](ci.md#partial-test-runs-filter-testsuite-path-args) for details.
For paratest / `pest --parallel`, the merge CLI accepts the same options as flags:
```bash
vendor/bin/gesso coverage:merge \
--spec-base-path=openapi/bundled \
--specs=front,admin \
--min-endpoint-coverage=80 \
--min-response-coverage=60 \
--min-sdk-exercise-coverage=100 \
--min-coverage-strict
```
The parallel strict SDK gate also requires every worker sidecar to contain SDK
exercise state. A mixed old/new worker fleet is incomplete evidence and fails;
warn-only mode reports the missing-worker count and evaluates the available
observations.
---
Source: https://studio-design.github.io/gesso/supported-features.md
# Supported features and known limitations
This is a contract-testing tool: where we can't enforce a constraint precisely, we prefer a loud failure or an explicit "skipped" outcome over silently accepting non-compliant data. The list below pins down what does and does not get checked so you can decide whether the gaps matter for your spec.
The schema-conversion behaviour described here is measured against the official JSON Schema Test Suite on every CI run, and the loader against the OpenAPI Initiative's own example documents — see [conformance](conformance.md) for the current results.
- [OpenAPI 3.0, 3.1, and 3.2](#openapi-30-31-and-32)
- [`readOnly` / `writeOnly` enforcement](#readonly--writeonly-enforcement)
- [Body validation](#body-validation)
- [Parameter styles](#parameter-styles)
- [Security schemes](#security-schemes)
- [Schema features](#schema-features)
- [HTTP methods](#http-methods)
- [Spec features not consulted](#spec-features-not-consulted)
- [Diagnostic channels (`E_USER_WARNING` and `E_USER_DEPRECATED`)](#diagnostic-channels-e_user_warning-and-e_user_deprecated)
## OpenAPI 3.0, 3.1, and 3.2
The package accepts OpenAPI `3.0.x`, `3.1.x`, and `3.2.x`. The root `openapi` field must be a string in explicit `major.minor.patch` form (for example, `3.0.4`, `3.1.2`, or `3.2.0`). Patch releases within a supported minor use the same feature set, following the [OpenAPI version policy](https://spec.openapis.org/oas/latest.html#versions-and-deprecation).
Missing, empty, non-string, malformed, and unsupported values fail spec loading with `InvalidOpenApiSpecException`. This includes Swagger / OpenAPI 2.x, OpenAPI 3.3.x, and unknown future versions. They are never interpreted as 3.0.
For supported versions, the package detects the OAS feature family from the `openapi` field and handles schema conversion accordingly:
| Feature | 3.0 handling | 3.1 / 3.2 handling |
|---|---|---|
| `nullable: true` | Converted to type array `["string", "null"]`; `null` appended to `enum` if present | Not applicable (uses type arrays natively) |
| Boolean `exclusiveMinimum` / `exclusiveMaximum` | Lowered with its numeric `minimum` / `maximum` to the Draft 07 numeric form | Native numeric JSON Schema keyword |
| `prefixItems` | N/A | Preserved and enforced natively by JSON Schema 2020-12 |
| `$dynamicRef` / `$dynamicAnchor` | N/A | Preserved and enforced natively |
| `examples` (array) | Removed (Draft 2020-12 keyword, not Draft 07) | Removed (Draft 2020-12 keyword, not Draft 07) |
| `const` | N/A | Preserved and enforced natively |
| `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 as an annotation on surviving properties |
For OpenAPI 3.1/3.2, the root `jsonSchemaDialect` supplies the default dialect and a resource-root Schema Object's `$schema` overrides it. The OpenAPI base dialect is evaluated as JSON Schema 2020-12 after OpenAPI-specific semantics (`readOnly` / `writeOnly` and `discriminator`) are applied. Opis-supported JSON Schema Draft 06, Draft 07, 2019-09, and 2020-12 declarations are accepted. Unknown custom dialects fail with `InvalidOpenApiSpecException` / `UnsupportedJsonSchemaDialect` instead of silently falling back to Draft 07.
OpenAPI 3.2 is backward compatible with 3.1, so ordinary 3.2 operations use the tested 3.1 conversion pipeline. The behavior below follows the official [OpenAPI 3.2 specification](https://spec.openapis.org/oas/v3.2.0.html) and [3.1-to-3.2 upgrade guide](https://learn.openapis.org/upgrading/v3.1-to-v3.2.html). The contract-relevant 3.2 additions have explicit behavior:
- `QUERY` works in direct validators, PSR-7, Laravel, Symfony, Pest, fuzz exploration, and coverage reports.
- Custom methods under `additionalOperations` resolve in direct request/response validators and the PSR-7 adapter, preserving their case-sensitive method spelling, and appear in coverage. The enum-based framework and fuzz adapters accept `QUERY` but not arbitrary custom method tokens; whole-spec exploration reports those operations as skipped with a reason instead of silently omitting them.
- One `in: querystring` parameter with `application/x-www-form-urlencoded` content validates the entire framework-parsed query map against its schema. Mixing it with `in: query`, declaring it more than once, or omitting its schema fails loudly. Other query-string media types emit `[OpenAPI 3.2 querystring]` because the public validator receives a parsed map rather than the original serialized query string.
- `discriminator.defaultMapping` is enforced for missing and unknown values when an explicit `mapping` is also present. With implicit mappings only, missing values use the fallback while unknown present values rely on the underlying `oneOf` / `anyOf`; `[OpenAPI 3.2 discriminator]` makes that residual limitation observable.
- `itemSchema` streaming bodies are returned as `Skipped` with a reason and matched content type. Framework adapters buffer a complete body, while the PSR-7 adapter refuses to consume a non-seekable JSON stream; neither path can safely apply a schema independently to each SSE, JSON Lines, JSON Text Sequence, or multipart stream item.
- A root `$self` emits `[OpenAPI 3.2 $self]`: relative references still resolve from the retrieved file path, so specs depending on a different `$self` base URI must be pre-bundled.
### `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.
## Body validation
- **JSON body types**: framework and PSR-7 adapters preserve objects and arrays at every depth. `{}` satisfies `type: object`; `[]` does not. Literal `null` is a present value and is type-checked, while an absent required body fails. Direct callers can retain the same distinctions using [`DecodedBody::fromJsonValue()`](api-reference.md#decodedbody); legacy associative-array inputs keep their historical empty-object compatibility behavior.
- **Required non-JSON requests**: Laravel and Symfony preserve body presence from raw content, parsed parameters, or uploaded files. PSR-7 defers opaque-body inspection until the resolved contract requires it, then uses parsed data, uploaded files, or stream size; an unknown-size stream is inspected only if its cursor can be restored. Unknown presence produces a read failure, not an empty-body diagnosis. Optional or undeclared opaque bodies are not read. This satisfies only the presence check; it does not imply that an opaque body's schema was validated.
- **Validated**: `application/json` and any `+json` structured-syntax suffix (RFC 6838), and content keys using ranges (`application/*`, `*/*`) — the matcher tries exact match first, then `/*`, then `*/*`.
- **Multi-JSON-per-status specs** (e.g. `application/json` + `application/problem+json` for the same status): when the actual response Content-Type is supplied, schema validation prefers the spec key that exactly matches the response Content-Type before falling back to the first JSON key. A problem-details body served as `application/problem+json` is judged against its own schema, not the success-shape `application/json` schema. Vendor `+json` suffixes the spec doesn't enumerate (e.g. `application/vnd.example.v1+json`) still fall through to the first JSON key, preserving the legacy interchangeable-JSON behaviour for that case. When that fallback fires **and the body then fails**, the failure ends with a `Note:` line — last, after any response-header errors — naming the undeclared Content-Type, the key the body was validated against, and the declared keys — the shape mismatch on its own reads as "the body is wrong" when the actual fix is to document the second media type. The same line is a `response.content_type` issue in the [JSON failure document](validation-json-schema.md). A body that satisfies the schema it landed on stays a plain success with no note.
- **Presence-only** (no schema validation): every other media type, including `application/xml`, `text/plain`, and `application/octet-stream` — and form bodies on the response side. The validator confirms the spec declares the content type but does not check the body. When the matched media-type entry declares a `schema` (OpenAPI permits a schema on any media type, but this JSON Schema engine cannot evaluate a non-JSON one), the orchestrator marks the response/request as `Skipped` with a `skipReason` so the unvalidated body is surfaced in coverage rather than counted as a clean pass. A non-JSON entry with no `schema` has nothing to validate and stays a plain success.
- **OpenAPI 3.2 streaming `itemSchema`**: explicitly `Skipped`, never counted as a clean validation. `prefixEncoding` / `itemEncoding` are therefore not enforced either.
- **Form request bodies** (`application/x-www-form-urlencoded`, `multipart/form-data`): validated against the media type's schema. Values arrive as strings and are coerced to the declared property types first, exactly as query parameters are, so `age=3` satisfies `type: integer` while `age=three` fails at `/age`. Framework adapters hand the parsed field map to the validator (the PSR-7 adapter uses a `ServerRequest`'s parsed body and uploaded files; a client `RequestInterface` carrying raw urlencoded bytes is parsed by the validator). A raw `multipart/form-data` payload with no parsed parts is not reassembled — it stays `Skipped` with a reason. Response-side form bodies remain presence-only.
- **Multipart file parts**: a file part's bytes are never read. Its presence satisfies `required`, its client filename stands in for the binary value (so a `minLength` / `pattern` on a binary property is measured against the filename, not the file), and its declared `Content-Type` is checked against the `encoding` object. Per RFC 7578 §4.4 a part with no Content-Type of its own counts as `text/plain` rather than matching anything. A file whose upload failed (anything other than `UPLOAD_ERR_OK` — no file sent, size limit, partial write) is dropped before validation, so it cannot satisfy a `required` part, and dropping it never leaves a hole in a file list.
- **Binary properties must arrive as file parts**: a property describing raw bytes fails if the request sent a plain field instead, and so does any element of an array of them (how a multi-file upload is described). Raw bytes means OAS 3.0 `format: binary`, OAS 3.1's `contentMediaType` with **no** `type` (a JSON string cannot hold arbitrary bytes, which is why the 3.1 form omits it), or the empty schema `{}` — whose default media type is the octet stream, making the OAS 3.2 multi-file example `type: array, items: {}` a list of files. A declared `type` rules it out: per JSON Schema 2020-12 `type: string` with a `contentMediaType` and no `contentEncoding` is identity-encoded UTF-8 text, so it is validated as an ordinary field whatever the media type says. `format: byte` and an explicit `contentEncoding` are text on the wire too.
- **Multipart `encoding` object**: `encoding..contentType` is enforced for file parts — a part whose Content-Type is outside the declared media type, range, or comma-separated list fails. A part whose contract admits nothing but JSON is decoded before its subschema is applied (parsing it confirms the declared type rather than guessing at it); when `contentType` is omitted the default is computed from the property type alone — `application/json` for `type: object`, the inner type's default for an array, `application/octet-stream` for a `format: binary` string or for any schema that declares no `type`, `text/plain` for the other primitives. Neither `contentMediaType` nor an untyped `properties` block feeds that computation, which is what OpenAPI prescribes.
- **Unconfirmable parts are `Skipped`, not passed**: form parsing keeps no per-part headers, so a non-file part's own Content-Type is not observable. OAS 3.2 §4.15.4.1 resolves several declared media types by that header and rules out content sniffing, so where it is needed — a mixed list such as `application/json, application/xml`, or a single non-text type such as `image/png` on a plain field — the body is returned as `Skipped` with a reason naming the part. The schema still runs, unmodified, against the real data: withheld are only the violations that point into such a part, or that another permitted reading of it disagrees about. Each unresolved part is re-read on its own terms — the JSON value its bytes decode to (objects stay objects, and a JSON string reads as that string) when JSON is among its candidates, plus shape probes when one of them cannot be materialised at all — and the readings of several parts are combined, since each part chooses its media type independently. An array part keeps its container in every reading, since `encoding` applies to its items. That keeps every object-level constraint (`required`, `minProperties`, `additionalProperties`, a composed `required`) honest, while an `if` / `oneOf` / `dependentRequired` branch keyed on the part cannot fabricate a root-level failure out of the raw-string reading. A violation both readings agree on is reported as a failure and takes precedence over the skip. When the parts combine into more readings than the validator enumerates (64), the readings are not sampled — an unchecked combination may be the one that excuses a violation — and only what no reading can reach survives: a reading changes nothing but the unresolved parts' own values, so the schema is reduced to the keywords that read no more than the key set and one property's value at a time (`type`, `required`, `min`/`maxProperties`, `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `dependentRequired`, plus `allOf` / `dependentSchemas` reduced the same way) and validated against the same data. What it still reports — an unconditional `required`, `minProperties`, a plain field's own type — stays a failure. The reduction is an allowlist, so a keyword reading the whole object (`enum`, `const`), a conditional one (`if` / `then` / `else`, `anyOf`, `oneOf`, `not`, `unevaluated*`), and anything a future dialect adds cannot survive by going unrecognised. The rest is left unconfirmed and stated in the skip reason; a `$ref` the loader did not inline leaves nothing provably unconditional, so everything is left unconfirmed then. `encoding..headers` / `style` / `explode` are not consulted.
- **Cascading `additionalProperties: false` errors** are stripped automatically. opis's `PropertiesKeyword` skips its `addCheckedProperties()` call whenever any sub-property fails its schema, leaving `$checked` empty in the validation context. The follow-on `additionalProperties: false` keyword then reports every property the data carries — including ones explicitly declared in the schema's `properties` — as "additional". The validator walks opis's `ValidationError` tree, reads the raw list of "additional" property names from `args()['properties']`, and filters out names that ARE declared in the schema's `properties` keyword at that path. A single failure shows as one error, not a paired pseudo-error naming declared properties as not-allowed. Genuine additional properties still surface; mixed cases keep only the real extras in the message. The walker descends through single-schema `items`, Draft 07 tuple-form `items`, and native 2020-12 `prefixItems`. Composition keywords and other ambiguous routing shapes fall through to keeping the original message untouched, so a real violation is never silently swallowed.
## Parameter styles
- **Query**: `style: form` + `explode: true` (the OAS default) expects repeated keys, parsed into arrays by the framework. For `type: array` schemas the non-exploded serializations are split on their delimiter before validation: `form` + `explode: false` on `,`, `pipeDelimited` on `|` / `%7C` (the OAS Style Examples percent-encode this delimiter), `spaceDelimited` on `%20` / `+`. The framework adapters (PSR-7, Laravel, Symfony) pass the raw query string through, so splitting happens before percent-decoding and a `%2C` inside a `form`-style value stays data (`role=owner%2Cadmin,member` → `["owner,admin", "member"]`); a delimiter character inside a `pipeDelimited` / `spaceDelimited` value is unrepresentable (undefined per OAS Appendix E). The raw value is only used when it decodes to the framework-parsed value (PSR-7 allows the two to diverge; the parsed map wins). Direct `OpenApiRequestValidator` callers get the same by passing `rawQueryString`, and without it the decoded value is split as a best effort. An empty value (`?role=`) is the one-element list `[""]` — RFC 6570 omits an empty list entirely. `deepObject` and non-exploded `type: object` parameters are not parsed; type-mismatch errors will surface but they will point at the wrong cause.
- **Query string (3.2)**: `in: querystring` with `application/x-www-form-urlencoded` validates the whole parsed query map. Other media types emit a categorized warning and skip query-string validation.
- **Header / Path**: only `style: simple` for scalar values. `type: array` and `type: object` parameters are not parsed (the raw string is fed to the schema, which then mismatches). `style: matrix` and `style: label` for path parameters are not handled — the prefix is not stripped before validation.
- **Cookie parameters** (`apiKey` security scheme aside): not validated.
- **`parameters[].content`**: read only for OpenAPI 3.2 `in: querystring`; other parameter locations still use `parameters[].schema` only.
## Security schemes
- **Validated**: `apiKey` (in `header` / `query` / `cookie`) and `http` + `bearer` — presence checks for the named header/query/cookie / RFC 6750 `Bearer` token.
- **Loud `E_USER_WARNING` on first encounter**: `oauth2`, `openIdConnect`, `mutualTLS`, and `http` schemes other than `bearer` (`basic`, `digest`). When every scheme in a security requirement is unsupported the requirement still passes (false-negative avoidance — blocking the test for a spec we cannot evaluate is worse than letting it through), but the validator fires a one-shot per-scheme-name warning so the silent pass does not stay invisible. The warning is emitted as a single line (shown wrapped here for readability):
```
[security] OAuth2 scheme 'oauth2_user' is silently passed (no token check) — POST /v1/users. The opis/json-schema-based validator cannot verify oauth2 / openIdConnect / mutualTLS / http-basic / http-digest credentials. Your test will not detect a missing or invalid token. Workaround: split the bearer-token surface into a separate test, or assert the Authorization header presence manually.
```
Under `phpunit.xml` `failOnWarning="true"` this surfaces as a test failure on first encounter — the recommended setting if your spec contains any of these scheme types, since green tests against unauthenticated requests are the worst-class silent failure for a contract-testing tool.
## Schema features
- **Validated in every supported dialect**: `type`, `enum`, `multipleOf`, `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum`, `minLength`/`maxLength`/`pattern`, `minItems`/`maxItems`/`uniqueItems`, `minProperties`/`maxProperties`/`required`, `additionalProperties` (`true` / `false` / schema), `allOf` / `oneOf` / `anyOf` / `not`.
- **Native in OpenAPI 3.1/3.2**: `const`, `prefixItems`, `$dynamicRef`, `$dynamicAnchor`, `unevaluatedProperties`, `unevaluatedItems`, `dependentSchemas`, and `dependentRequired`. These are preserved and delegated to the selected JSON Schema dialect rather than lowered or discarded.
- **`$ref` sibling keywords** (#536): where the effective JSON Schema dialect is 2019-09 or later — the OpenAPI 3.1/3.2 default — a Schema Object `$ref` is an in-place applicator, so keywords written next to it apply alongside the resolved target: `{$ref: '#/components/schemas/Name', minLength: 4}` enforces both. Reference resolution merges the two into one Schema Object (`{type: string, minLength: 4}`) rather than composing them as `allOf` branches. That flat merge is what keeps the resolved document readable by everything downstream that reads a schema's own top level: parameter/header coercion and query-style splitting read `type` and `items`, form decoding reads `properties`, `unevaluatedProperties` / `unevaluatedItems` read the annotations of *adjacent* keywords, and `readOnly` / `writeOnly` are enforced from the property schema's own top level. A keyword both sides declare is merged by its own semantics: property maps (`properties`, `patternProperties`, `dependentSchemas`, `$defs`) merge per name and recurse — with the boolean schemas `false` and `true` treated as the absorbing and identity elements they are, so a sibling `true` never re-opens a property the target closed with `false` — `required` unions, bounds tighten to the stricter one, and an identical restatement collapses to one. Merging only ever rewrites a well-formed schema: a malformed side of a collision (`minLength: "3"`, `required: [1]`, a `properties` or a subschema written as a JSON array, an empty or non-schema `allOf`, a `$schema` that is not a URI string) is kept exactly as written, because repairing it would turn a spec error into a schema the validator accepts. A lone keyword is never lifted out into a branch of its own — keywords in a schema object work on each other, so separating an `if` from its `then`, or an `unevaluatedProperties` from the `properties` it reads, changes what the schema means. When a collision has no meaning-preserving merge, the siblings are applied whole as an adjacent `allOf` branch, leaving the target's own top level untouched. The same fallback covers the one interaction that is not a collision: `additionalProperties` applies to the names *its own adjacent* `properties` do not match, so a side that constrains it reaches across to names only the other side declares — merging them into one object would exempt them, and with `additionalProperties: false` that turns a schema nothing can satisfy into one that accepts the union. (`unevaluatedProperties` is different: it reads the annotations of adjacent in-place applicators, `$ref` among them, so the flat merge is exactly what it already meant.) A target that declares its own `$schema` is a schema resource with a dialect of its own, and merging would put the *siblings* under that dialect, so unless it is the same dialect the referring schema is read under, the target is applied whole as a branch instead. Same *dialect*, not merely the same `$ref`-sibling rule: 2019-09 and 2020-12 both apply siblings and still disagree on array tuples (`items` vs `prefixItems`). Coercion follows through the branch — `TypeCoercer` reads `type` and `items` through `allOf`, and because `allOf` ANDs, the type it coerces to is the intersection of the declared type sets (with `integer` treated as the subset of `number` it is, so a union offering both coerces as `number`) and the item schema it coerces against is the conjunction of every `items` that applies, rather than whichever one sits at the top level. The same resource rule applies to a referenced external document — its own root `$schema` (or `jsonSchemaDialect`) decides whether `$ref` siblings apply inside it, not the entry document's — and to any schema resource that encloses the referenced fragment, in an external document or the entry document alike. A pointer is resolved against its document, so the referring schema's resource has no say: the dialect resets to the document's before the target's own ancestors are applied. Substitution would otherwise lift the target out of the resource that governed it, so the resource's own `$schema` declaration is written onto the resolved schema — without it a Draft 07 tuple `items: [ … ]` pulled into a 2020-12 document would be read as the single-schema 2020-12 `items` and rejected. It is re-attached verbatim: a `$schema` naming no dialect this package reads — a non-string value, or an unsupported URI — selects none, so nothing inside that resource applies `$ref` siblings or claims a dialect of its own, and the declaration always travels with the target: onto it, onto an `allOf` wrapper when the target is a resource root of its own, and back onto whatever replaced a `{$schema, $ref}` node. The converter then reports it wherever the target is referenced from, not only where the resource itself is. Siblings that carry no validation weight (`summary`, `description`, `title`, `$comment`, `deprecated`, `example`, `examples`, `externalDocs`, `xml`, and any `x-` specification extension) leave the node a plain substitution, so the common `{$ref, description}` and `{$ref, x-…}` shapes keep their old resolved form. The rule is positional: a Reference Object outside a Schema Object position — a Response, Parameter, Path Item, or Example reference — always resolves by substitution. Draft 06/07 require the opposite ("All other properties in a `$ref` object MUST be ignored"), so plain substitution also stays for OpenAPI 3.0 and for any 3.1/3.2 document — or single schema resource — that selects Draft 06/07 through `jsonSchemaDialect` or `$schema`.
- **`format`** (validated by opis Draft 06+): the canonical 19-entry set (`email`, `uuid`, `date`, `date-time`, `uri`, `ipv4`, `ipv6`, `hostname`, `regex`, `json-pointer`, …). The full list is the authoritative `KNOWN_OPIS_FORMATS` constant in `src/Spec/OpenApiSchemaConverter.php` — keeping it in one place avoids drift when opis adds formats. Unknown values (e.g. `format: emial` typo for `email`) emit a one-shot `E_USER_WARNING` per format value, since opis silently accepts any value for unrecognised formats. Non-string `format` values fire a separate malformed-spec warning.
- **Advisory `format`** (deliberately not enforced, no warning): `int32`, `int64`, `float`, `double`, `byte`, `binary`, `password`. Treated as documentation hints per OAS conventions; see `ADVISORY_FORMATS` constant.
- **Empty Schema Object `{}`** (#478): a `{}` in a schema position — `properties: {x: {}}`, `additionalProperties: {}`, `items: {}`, `not: {}`, `if`/`then`/`else`, `contains`, `propertyNames`, `patternProperties` values, `dependencies` / `dependentSchemas` values, `$defs` values — means "any value" and is normalised to the equivalent boolean schema `true`, because specs are decoded with `json_decode(..., true)` and `{}` is otherwise indistinguishable from `[]`. An empty map-valued keyword (`properties: {}`, `patternProperties: {}`, `dependencies: {}`, `dependentSchemas: {}`, `dependentRequired: {}`, `$defs: {}`, `definitions: {}`) constrains nothing and is dropped. Keyword-value positions keep empty arrays as arrays: `required: []` and `prefixItems: []` validate as written, and `enum: []` / `allOf: []` / `anyOf: []` / `oneOf: []` are rejected by opis as the empty-array forms its drafts forbid — Gesso does not rewrite them into "any value".
- **Lowered**: `discriminator` + `mapping` / `defaultMapping` → an `allOf` of `if`/`then` conditionals (default; see `discriminator` below). OpenAPI 3.0 `nullable` is lowered for Draft 07 compatibility.
- **Stripped**: `xml`, `externalDocs`, `example` / `examples`, `deprecated`, and OAS-only `nullable`/`readOnly`/`writeOnly` after enforcement (3.0). `discriminator` is also stripped when enforcement is turned off (`enforce_discriminator: false`).
- **Validated via opis (Draft 06+)**: `patternProperties`, `contentMediaType`, `contentEncoding`. These are JSON Schema keywords that opis implements natively, so your constraints are enforced.
- **OpenAPI 3.0 compatibility warnings**: `unevaluatedProperties`, `unevaluatedItems`, `dependentSchemas`, and `dependentRequired` still emit `E_USER_WARNING` when placed in a 3.0 Schema Object because its compatibility pipeline targets Draft 07.
- **`contentSchema`**: preserved in 3.1/3.2. JSON Schema defines the content vocabulary as annotation-only by default, so it is not treated as a decoded-body assertion unless the validator's content hooks support that media type.
- **`discriminator`** (enforced by default, #262): when a schema declares `discriminator` with a non-empty `mapping`, the converter lowers it into an `allOf` of an unknown-value guard (the discriminator property must be present and one of the mapping keys) plus one `if`/`then` per mapping value, where `then` is the resolved subtype schema. The discriminator value therefore steers validation toward a single branch — a body that lies about its type (e.g. `kty: RSA` carrying EC-only fields) fails instead of passing the underlying `oneOf` / `anyOf` union. This is stricter than the OAS spec strictly requires (the discriminator is officially a tooling hint), which is exactly what a contract-testing tool wants. No `E_USER_WARNING` is emitted.
- **Opt out**: set `enforce_discriminator: false` (Laravel config) or `` (the PHPUnit extension; `0` / `no` also work) to restore the historical behaviour — `discriminator` is stripped and the mapping is not enforced (and no warning is emitted).
- **Malformed blocks**: with enforcement on, a structurally invalid `discriminator` (missing/non-string `propertyName`, non-array `mapping`, non-string mapping value, an unresolvable mapping pointer, or a pointer to a non-object) surfaces as a loud validation failure rather than silently passing.
- **OpenAPI 3.2 `defaultMapping`**: with explicit mapping keys, absent and unknown discriminator values validate against the fallback schema. Without explicit keys, only the absent-value fallback can be reconstructed after eager `$ref` resolution; a categorized warning exposes the unknown-value limitation.
- **Known limitation**: a self-referential discriminator chain (a subtype that, via `allOf` + `$ref`, re-contains the *same* base discriminator — the inheritance idiom) is enforced at the first recursion level; the inner re-appearance of that same discriminator is stripped without re-lowering (the outer branch already routes to and enforces that exact subtype). This terminates the lowering and avoids combinatorial blow-up while still enforcing the outer branch selection. Subtype-specific constraints (e.g. `required`) are unaffected — they live in the outer `then`.
- **`nullable` + `discriminator`** (3.0): a `null` body fails the discriminated-object branch (the lowered guard requires the discriminator property). Model a null-tolerant polymorphic field with an explicit `oneOf` including `{type: 'null'}` if needed.
- **`readOnly` / `writeOnly`**: enforced at the property's own top level only (see [readOnly / writeOnly enforcement](#readonly--writeonly-enforcement)).
## HTTP methods
The PHPUnit coverage report counts `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, OpenAPI 3.2 `QUERY`, and every custom method declared under `additionalOperations`. Laravel, Symfony, Pest, and the fuzz explorer accept the six named enum methods including `QUERY`; arbitrary custom tokens are supported by the direct validators, PSR-7 adapter, and coverage tracker. Whole-spec exploration enumerates `HEAD`, `OPTIONS`, `TRACE`, and case-sensitive custom methods but records them as explicit skips because it cannot dispatch them through the enum-based generated-case API. Direct validator and PSR-7 adapter calls can still resolve them.
## Spec features not consulted
Webhooks (3.1+), Callbacks, Response `Links`, Server URL templating (`servers` with `variables` — never used to match a request path, read only to explain a failed match; see [Server base paths are not stripped automatically](setup.md#server-base-paths-are-not-stripped-automatically)), Examples (`example` / `examples`, including 3.2 `dataValue` / `serializedValue` — not used for fuzzing or validation), 3.2 tag hierarchy (`summary` / `parent` / `kind`), `externalDocs`, and vendor extensions (`x-*` keys, ignored harmlessly). OAuth2 device authorization and other OAuth/OpenID schemes remain on the existing `[security]` warning path.
## Diagnostic channels (`E_USER_WARNING` and `E_USER_DEPRECATED`)
The library uses PHP's native `trigger_error(..., E_USER_WARNING)` as the loud-signal channel for silent-pass conditions the validator cannot enforce. **This is the v1.0 official API**: warnings are dedup'd per-process and prefixed with a category tag so callers can route or filter them mechanically.
A second channel, `trigger_error(..., E_USER_DEPRECATED)`, carries migration notices for surfaces that a future major removes. It follows the same dedup and category-prefix contract, but uses a distinct error level and a distinct prefix so an error handler can route migration work separately from operational signals. The `Level` column below says which channel each prefix arrives on.
| Category prefix | Level | Source | Dedup key |
|---|---|---|---|
| `[security]` | `E_USER_WARNING` | `SecurityValidator` (`oauth2`, `openIdConnect`, `mutualTLS`, `http-basic`, `http-digest`) | scheme name |
| `[OpenAPI Schema]` | `E_USER_WARNING` | `OpenApiSchemaConverter` (3.0-only `unevaluated*` / `dependent*`, unknown / malformed `format`) | per-keyword / per-format-value |
| `[OpenAPI 3.2 querystring]` | `E_USER_WARNING` | `QueryParameterValidator` (serialized query media type cannot be reconstructed) | declared media-type set |
| `[OpenAPI 3.2 discriminator]` | `E_USER_WARNING` | `OpenApiSchemaConverter` (`defaultMapping` with implicit mappings only) | process-wide limitation key |
| `[OpenAPI 3.2 $self]` | `E_USER_WARNING` | `OpenApiSpecLoader` (`$self` base URI is not applied) | spec load/cache |
| `[Gesso deprecation]` | `E_USER_DEPRECATED` | `Internal\Deprecations` (every deprecated configuration key, CLI flag, and PHP symbol) | deprecation id |
**Not on either channel:** a rename whose old spelling still works writes a plain `[Gesso] WARNING:` line to STDERR instead — going through `E_USER_DEPRECATED` would make a `failOnDeprecation` suite fail for using a name that is still supported. The three `OPENAPI_*` environment variables and the `openapi:routes` / `openapi:stubs` Artisan commands are the current members; see [renamed spellings still accepted](versioning.md#renamed-spellings-still-accepted).
**Deprecation notices:** each notice names what to use instead and the version that removes the surface, because the emitter cannot represent one that does not. Under the PHPUnit extension, one summary line is written to STDERR after the run listing every deprecated surface still in use with its call count; nothing is written when the run used none. Under paratest each worker stages its counts in the sidecar and `gesso coverage:merge` writes the one summed line, so the report survives a parallel run. Inside a booted Laravel app the framework's test error handler absorbs the raw `E_USER_DEPRECATED` — `failOnDeprecation` does not trip and the capture recipe below sees nothing — but the summary line is unaffected: it renders recorded counts, not the error channel. See [Deprecations in `UPGRADING.md`](https://github.com/studio-design/gesso/blob/main/UPGRADING.md#deprecations) for the current list and [versioning](versioning.md) for the rule that ties a removal to a prior deprecation.
**How to consume:**
- **Default** (PHPUnit `failOnWarning="true"`): the first warning fails the test. Recommended for contract-testing pipelines, since silent-pass on auth or unknown formats is the worst-class failure mode.
- **Stay green, surface warnings in output**: omit `failOnWarning` (PHPUnit 10+ default is `false`). Warnings show in the test report but do not fail.
- **Capture programmatically** (e.g. for a custom report):
```php
set_error_handler(static function (int $errno, string $errstr): bool {
if ($errno === E_USER_WARNING && str_starts_with($errstr, '[security]')) {
MyReport::record($errstr);
return true; // suppress
}
return false; // bubble
});
```
- **Acknowledge a specific unvalidatable security scheme**: for the `[security]` category, prefer the scheme-scoped `acknowledged_unvalidatable_schemes` setting (Laravel config key, PHPUnit extension parameter, `gesso doctor --acknowledge-unvalidatable-scheme`) over an error handler — see [Acknowledging an unvalidatable security scheme](setup.md#acknowledging-an-unvalidatable-security-scheme). Only the named schemes stop warning; an acknowledged name that is absent from the spec, or that the validator can actually enforce, itself warns so the list cannot rot (those rot warnings carry the `[Gesso]` prefix).
- **Suppress one category** (e.g. acknowledged limitation in another category): match on the category prefix in your error handler. Do not blanket-suppress all `E_USER_WARNING`s — unrelated warnings would silently disappear.
**Why not exceptions / PSR-3 logger / structured payload on `OpenApiValidationResult`?** The simple channel is zero-dep, integrates with every PHP framework's existing error handler, and stays out of the v1.0 SemVer surface. A structured channel (`WarningCollector`, PSR-3 sink, or `result->warnings()`) can be added in v1.x as additive without breaking — we are deliberately deferring until real-world usage demands it. See [issue #149](https://github.com/studio-design/gesso/issues/149) for the design discussion.
**Per-process dedup vs per-test:** the dedup state is process-global. PHPUnit runs all tests in one process by default, so a warning fired in test A is not fired again in test B even if both schemas exhibit the issue. The `*::resetWarningStateForTesting()` helpers (annotated `@internal`) exist as test seams for the converter / security validator's own tests; downstream tests rarely need them.
---
Source: https://studio-design.github.io/gesso/conformance.md
# Conformance
Gesso is measured against two corpora nobody here maintains, both pinned by
commit SHA in `composer.json` and asserted on every CI run:
| Corpus | Question it answers | Result |
| --- | --- | --- |
| [OAI/learn.openapis.org][learn] | Does Gesso understand the documents the OpenAPI Initiative itself publishes? | 22 of 22 files load without error; one document reports three not-enforced security schemes |
| [JSON Schema Test Suite][suite] | What does Gesso's schema rewriting change about a schema's meaning? | 11 of 3,784 verdicts move; each one listed with a reason |
Both results are committed as machine-readable baselines, so a number can only
move when someone updates the record.
## OpenAPI example documents
Corpus: [`OAI/learn.openapis.org`][learn] at
`43756549c27cbf84107b190b82c65e0336f2f09f` (CC-BY-4.0) — the example API
descriptions published by the OpenAPI Initiative alongside the specification.
Every document for OAS 3.0, 3.1, and 3.2 is loaded through `OpenApiSpecLoader`
and diagnosed by `gesso doctor`, in **both** its JSON and its YAML form: 11
documents, 22 files. The `examples/v2.0` directory is Swagger 2.0, which this
package does not accept by design, and is not measured.
| Document | OAS | Operations | Responses | Diagnosis |
| --- | --- | ---: | ---: | --- |
| `v3.0/api-with-examples` | 3.0.0 | 2 | 4 | clean |
| `v3.0/callback-example` | 3.0.0 | 1 | 1 | clean |
| `v3.0/link-example` | 3.0.0 | 6 | 6 | clean |
| `v3.0/petstore` | 3.0.0 | 3 | 6 | clean |
| `v3.0/petstore-expanded` | 3.0.0 | 4 | 8 | clean |
| `v3.0/uspto` | 3.0.1 | 3 | 5 | clean |
| `v3.1/non-oauth-scopes` | 3.1.0 | 1 | 0 | clean |
| `v3.1/tictactoe` | 3.1.0 | 3 | 5 JSON / 6 YAML | `warning` — 3 security schemes reported as not enforced |
| `v3.1/webhook-example` | 3.1.0 | 0 | 0 | clean; `webhooks`-only, nothing to enforce |
| `v3.2/3.2-query-example` | 3.2.0 | 1 | 1 | clean |
| `v3.2/3.2-tags-example` | 3.2.0 | 4 | 0 | clean |
Nothing here produces an error: `gesso doctor` exits `0` on all 22 files and
reports `status: ok` for ten of the eleven documents. `tictactoe` is the
exception at `status: warning`, from three `skipped` diagnostics — its HTTP
Basic and OAuth2 schemes are recognized and resolved, then reported as not
enforced rather than silently passed. "Clean" in the table above means no
issue of any severity.
Recorded in `tests/fixtures/compatibility/v1-oas-example-documents.json` and
asserted by `tests/Integration/Conformance/OasExampleDocumentTest.php`. Issues
are pinned as `severity/category` pairs, not prose: message wording is not a
compatibility surface (see [versioning](versioning.md)) and is pinned by the
doctor's own unit tests.
Four things the table is deliberately not hiding:
- **`webhook-example` yields zero operations.** The document describes only
`webhooks`, which Gesso deliberately does not consult (see
[supported features](supported-features.md#spec-features-not-consulted)). It
loads without complaint, and the zero is the honest signal — not a pass.
- **`tictactoe` counts 5 responses as JSON and 6 as YAML.** That difference is
upstream, not in the pipeline: the YAML form declares a `202` on
`POST /board/{row}/{column}` that the JSON form omits. Every other document
is diagnosed identically in both serializations, and the test asserts that.
- **The YAML forms are copied out of the corpus before they are read.** The
corpus ships `petstore.json` and `petstore.yaml` side by side, and the loader
resolves a bare spec name to the JSON sibling first — the doctor reports that
shadowing as a configuration error rather than diagnosing a file the runtime
would not have loaded. Copying the YAML forms into a tree of their own
exercises the YAML pipeline for real instead of pinning that diagnostic.
- **Three of these documents used to fail.** `webhook-example`,
`non-oauth-scopes`, and `3.2-tags-example` omit `paths` or an operation's
`responses`, which OAS 3.1 made optional; the doctor reported all three as
structure errors until [#479](https://github.com/studio-design/gesso/pull/479)
made the check version-aware. That is what this corpus is for.
## JSON Schema conversion delta
Gesso rewrites your OpenAPI Schema Objects before validating anything: OAS 3.0
is lowered to Draft 07, `discriminator` becomes `if`/`then` conditionals,
`nullable` becomes a type array, annotation keywords are stripped, and
`readOnly` / `writeOnly` become boolean subschemas. That rewriting is the part
of a contract-testing tool most likely to quietly change what your spec means.
This section measures exactly where it does. Of 3,784 official test-suite cases
put through both pipelines, conversion changes the verdict on 11, all of them
deliberate. Every one is listed with a reason.
### What is measured
3,784 of the 3,824 cases in the [official JSON Schema Test Suite][suite] are
validated twice:
1. **bare** — the schema exactly as the suite wrote it, through
[opis/json-schema][opis] alone;
2. **converted** — the same schema after
`Studio\Gesso\Spec\OpenApiSchemaConverter::convert()`, through the same
validator.
The remaining 40 are not compared at all. 4 are excluded outright (see below),
and 36 use a boolean `true` / `false` root schema, which a Schema Object cannot
express — `convert()` has no counterpart to produce, so there is no second
verdict to compare against. Both counts are published in the baseline rather
than silently dropped.
Only the cases where the two verdicts differ are recorded. The recorded set is
committed as
`tests/fixtures/compatibility/v1-json-schema-conversion-delta.json`
and asserted by `tests/Integration/Conformance/JsonSchemaConversionDeltaTest.php`
on every CI run. A new entry fails the build; so does a disappeared one.
**This does not claim that Gesso is a conformant JSON Schema validator.**
That claim belongs to opis, and it is measured independently by
[Bowtie][bowtie] via the `php-opis-json-schema` harness — a report Gesso has no
hand in producing. What is measured here is the delta Gesso adds on top of
opis, because that is the part no external report covers.
### Current result
Corpus: [`json-schema-org/JSON-Schema-Test-Suite`][suite] at
`cf2e5e0ff2e3d90239c3b59e68ac4c080bd4ac92` (MIT), pinned by commit SHA in
`composer.json`. `composer.lock` is not committed for a library, so
`composer.json` is the pin.
| Suite | OAS pipeline | In corpus | Excluded | Boolean root | Compared | Verdict changed |
| --- | --- | ---: | ---: | ---: | ---: | ---: |
| `draft7` | 3.0 → Draft 07 | 1,657 | 0 | 18 | 1,639 | 0 |
| `draft2020-12` | 3.1 / 3.2 → 2020-12 | 2,167 | 4 | 18 | 2,145 | 11 |
| **Total** | | **3,824** | **4** | **36** | **3,784** | **11** |
Required and `optional/` cases are both included, `optional/format/` among
them. The suite's `remotes/` directory is served at `http://localhost:1234/` as
the suite expects, so remote `$ref` cases genuinely resolve instead of failing
identically on both sides.
OAS 3.2 shares the 3.1 conversion pipeline, so running it would reproduce the
3.1 numbers exactly and it is not run twice.
#### The 11 differences
Two causes. Each recorded case cites one of them by key in the baseline's
`reasons` map. The `draft7` suite converts with no verdict change at all.
**`unsupported-dialect` — 9 cases** (`vocabulary.json`,
`format-assertion.json`). These declare a custom metaschema URI in `$schema`.
`OpenApiSchemaDialect::assertSupported()` rejects unknown dialects with
`InvalidOpenApiSpecException` instead of falling back to one with different
semantics. Deliberate, and documented in
[supported features](supported-features.md#openapi-30-31-and-32).
**`stripped-annotation-ref` — 2 cases** (`refOfUnknownKeyword.json`). The
schema points `$ref` at `#/examples/0`. The converter strips `examples` as an
OAS annotation keyword, so the reference target no longer exists and opis
reports an unresolved reference. A spec that references into an `examples`
array is unsupported; the outcome is a loud error, never a silent pass.
Referencing annotation internals is not something OpenAPI sanctions, so this is
documented rather than fixed.
#### The 4 exclusions
`unevaluatedItems with $dynamicRef` and `unevaluatedProperties with $dynamicRef`
(2020-12) are skipped before they run. Bare opis recurses without bound on them
and exhausts the stack — the conversion layer is not involved, and the
resulting fatal error is not catchable from PHP, so the groups have to be
excluded statically. They are listed in the baseline with that reason so the
exclusion stays visible instead of silently shrinking the corpus.
## Running it
```bash
composer install # fetches both pinned corpora into vendor/
vendor/bin/phpunit tests/Integration/Conformance
```
Both corpora are normal dev dependencies, declared as inline `repositories`
entries in `composer.json` because neither is published to Packagist and
neither carries usable upstream tags. `composer.lock` is not committed for a
library, so `composer.json` is the pin.
## Updating a pinned corpus
1. Bump `dist.url`, `dist.reference`, and `source.reference` in the
`composer.json` repository entry to the new commit SHA, and bump the
package `version` (Composer will not refresh a package repository unless the
version changes).
2. `composer update json-schema-org/json-schema-test-suite` or
`composer update oai/learn.openapis.org`.
3. Run the test. It will report the new counts and anything that moved.
4. Update `corpus.commit` and the moved entries in the corresponding baseline,
and update the tables above.
For the conversion delta, every entry must cite a key in the baseline's
`reasons` map. A delta citing an unknown reason, or a published reason no
longer cited by anything, fails a second assertion, so an unexplained
regression cannot be papered over by regenerating the file.
If a newly added JSON Schema group triggers the same unbounded opis recursion,
the test process will die with a fatal error rather than fail cleanly. Add the
group to `EXCLUDED_GROUPS` in the test and to `excluded_groups` in the baseline.
A newly added OpenAPI example document simply appears as an extra entry, and a
removed one as a missing entry; both fail until the baseline records the change.
[suite]: https://github.com/json-schema-org/JSON-Schema-Test-Suite
[learn]: https://github.com/OAI/learn.openapis.org
[opis]: https://opis.io/json-schema
[bowtie]: https://bowtie.report
---
Source: https://studio-design.github.io/gesso/api-reference.md
# API Reference
- [`OpenApiResponseValidator`](#openapiresponsevalidator)
- [`DecodedBody`](#decodedbody)
- [`OpenApiPsr7Validator`](#openapipsr7validator)
- [`OpenApiResponseExplorer`](#openapiresponseexplorer)
- [`OpenApiSpecExplorer`](#openapispecexplorer)
- [`OpenApiContractChecks`](#openapicontractchecks)
- [`OpenApiSpecLoader`](#openapispecloader)
- [`OpenApiCoverageTracker`](#openapicoveragetracker)
## `DecodedBody`
Both core validators accept a `DecodedBody` as their body argument. Use it
when decoding wire JSON yourself so `{}`, `[]`, literal `null`, and an absent
body remain distinct:
```php
use Studio\Gesso\DecodedBody;
$body = $rawBody === ''
? DecodedBody::absent()
: DecodedBody::fromJsonValue(json_decode($rawBody, false, flags: JSON_THROW_ON_ERROR));
```
`fromJsonValue()` wraps an already-decoded value; it does not parse JSON text.
Decode with `associative: false`, including nested objects. The validators then
know that an empty array is genuinely `[]`, so it fails `type: object`.
Framework and PSR-7 adapters do this for you; decoder provenance is internal.
Existing bare PHP arrays and `DecodedBody::present($value)` retain their legacy
behavior: a top-level empty array is interpreted as an empty object when the
schema explicitly accepts objects. Bare `null` still means an absent body;
`DecodedBody::present(null)` and `DecodedBody::fromJsonValue(null)` mean present
literal null. `DecodedBody::fromLegacy()` preserves an existing envelope.
## `OpenApiResponseValidator`
Main validator class. Validates a response body against the spec.
The constructor requires a `StrictRequiredTracker` that receives successful
response-body observations. Reuse one tracker for validators that belong to the
same test run. The optional `maxErrors` parameter (default: `20`) 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. When a non-JSON content type matches a spec media-type key that declares a `schema`, the body cannot be evaluated by the JSON Schema engine — the result is reported as `Skipped` (with a `skipReason`) rather than a clean success, so the unvalidated body is not miscounted.
```php
$tracker = new StrictRequiredTracker();
$validator = new OpenApiResponseValidator($tracker, 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->issues(); // list (structured view of errors())
$result->matchedPath(); // ?string (e.g., '/v1/pets/{petId}')
$result->skipReason(); // ?string (non-null when skipped)
```
`issues()` mirrors `errors()` one-to-one: each `ValidationIssue` carries the
same `message` plus a stable `category` slug (e.g. `request.security`,
`response.body` — the full list is in
[versioning.md](versioning.md#whats-covered-by-semver)) and the operation
context the validator resolved (`method`, `path`, `statusCode`,
`contentType`). A context field is `null` when that dimension does not apply
or was not resolved — request-side issues never carry a `statusCode`, and
`contentType` is set only on body issues and on the `response.content_type`
note (the resolved spec media-type key in both cases).
Assert on `category` and context instead of message wording —
the prose is not a compatibility surface. On schema violations,
`instancePath` carries the RFC 6901 JSON Pointer (`''` = document root —
the `message` prefix renders it as `[/]` for historical reasons) and
`keyword` the failing JSON Schema keyword (`type`, `required`, …): for
`request.body` / `response.body` issues the pointer is into the validated
body, for parameter / response-header issues into the named value.
`keyword` also carries synthetic violation kinds — `required` when a
required parameter, header, or security credential is missing, `format`
when a credential is present but not usable, and `parse` when the PSR-7
adapter could not read or JSON-decode a body. Both stay `null` for
structural and spec-malformation errors, including non-schema body errors
such as a missing required body. Results built by code that predates the
structured API derive issues with category `unknown`.
To hand the full result to machine consumers (CI ingestion, IDE annotations),
render it as a versioned JSON document:
```php
use Studio\Gesso\JsonValidationResultRenderer;
$json = JsonValidationResultRenderer::render($result);
// Optionally embed a (pre-redacted) reproduction command:
$json = JsonValidationResultRenderer::render($result, $curlCommand);
```
The document shape (`schema_version` 1) is a compatibility surface — see
[validation-json-schema.md](validation-json-schema.md).
Framework adapters (Laravel, Symfony, Pest, PSR-7) can emit the same document
in their assertion failure messages instead of the plain text shape. Select
the mode process-wide with the `GESSO_VALIDATION_FORMAT` environment
variable (`text` | `json`), programmatically, or via the PHPUnit extension's
`validation_output` parameter — the environment variable wins when set:
```php
use Studio\Gesso\ValidationOutput;
use Studio\Gesso\ValidationOutputFormat;
ValidationOutput::use(ValidationOutputFormat::Json); // e.g. in tests/bootstrap.php
ValidationOutput::reset(); // restore the text default
```
See [validation-json-schema.md](validation-json-schema.md#selecting-json-failure-output-in-adapters)
for the failure message shape and resolution rules.
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:
```php
use PHPUnit\Framework\AssertionFailedError;
use Studio\Gesso\OpenApiValidationOutcome;
match ($result->outcome()) {
OpenApiValidationOutcome::Success => null, // schema matched
OpenApiValidationOutcome::Failure => throw new AssertionFailedError($result->errorMessage()),
OpenApiValidationOutcome::Skipped => logger()->info('skipped', ['reason' => $result->skipReason()]),
};
```
## `OpenApiPsr7Validator`
Adapts PSR-7 messages to the request and response validators and records the
same coverage as framework integrations:
```php
use Studio\Gesso\Psr7\OpenApiPsr7Validator;
$validator = new OpenApiPsr7Validator('front');
$exchange = $validator->validateExchange($request, $response);
$requestResult = $exchange->requestResult();
$responseResult = $exchange->responseResult();
```
Use `validateRequest()`, `validateResponse()`, or
`validateResponseForOperation()` when only one side is available. See the
[PSR-7 guide](psr7.md) for PHPUnit assertions, stream handling, and a PSR-15
test integration recipe.
## `OpenApiResponseExplorer`
Generates deterministic, branch-complete valid payloads for one response
schema and returns a non-empty `GeneratedResponseCases` collection:
```php
use Studio\Gesso\Fuzz\GeneratedResponseCase;
use Studio\Gesso\Fuzz\OpenApiResponseExplorer;
$cases = OpenApiResponseExplorer::explore(
specName: 'front',
method: 'POST',
path: '/oauth/introspect',
status: 200,
contentType: 'application/json', // optional; first JSON type when null
seed: 1,
extraCases: 2,
);
$cases->each(function (GeneratedResponseCase $case): void {
$decoded = sdk_decode($case->bodyAsObject());
$case->assertRoundTrip(sdk_encode($decoded));
});
```
To exercise an SDK model by its exact, case-sensitive
`components.schemas` name without inventing an operation, use the same facade:
```php
$cases = OpenApiResponseExplorer::exploreComponent(
specName: 'front',
schemaName: 'IntrospectResponse',
seed: 1,
extraCases: 2,
);
$cases->each(function (GeneratedResponseCase $case): void {
$decoded = sdk_decode($case->bodyAsObject());
$case->assertRoundTrip(sdk_encode($decoded));
});
```
For explicit mappings across every selected operation response, build a
spec-wide plan:
```php
$summary = OpenApiResponseExplorer::exploreSpec('front', seed: 1)
->includeTags(['public'])
->mapResponse(
'introspect',
200,
fn (GeneratedResponseCase $case) => sdk_decode($case->bodyAsObject()),
fn (mixed $model) => sdk_encode($model),
)
->failOnUnmapped()
->assertRoundTrips();
```
`OpenApiResponseSpecExploration` exposes the same tag, method, path,
operation-ID, and deprecated filters as `OpenApiSpecExploration`.
`mapResponse()` accepts an exact status, an OpenAPI range key, or `default`.
The returned `ResponseSpecExplorationSummary` contains execution counts,
executed `ExploredOperation` rows, categorized `decodeFailures` and
`roundTripFailures`, and `ResponseSpecExplorationSkip` rows. Callback failures
produce one aggregate assertion after all cases run; each
`ResponseSpecExplorationFailure` preserves the original throwable and replay
metadata. Mapping gaps are skips unless `failOnUnmapped()` is enabled.
Named components are converted with the spec's OpenAPI version and JSON Schema
dialect, response-side read/write semantics, and discriminator context. Their
cases have `null` `status` and `contentType` because no operation was selected;
`replaySnippet()` renders the corresponding `exploreComponent()` call.
Unknown, malformed, recursive, and otherwise unsupported schemas throw loudly
instead of returning an empty collection.
The collection is `Countable` and `IteratorAggregate`. Each readonly case
exposes `body`, nullable `status` and `contentType`, `seed`, `caseIndex`, and
`pinnedBranch`; `bodyAsObject()` supplies decoded JSON shapes to SDKs,
`bodyAsArray()` is a lossy compatibility view for array-typed consumers (empty
objects become arrays, including nested ones), and `replaySnippet()` renders a
focused reproduction. `assertRoundTrip()` first validates the SDK output
against the exact converted response schema, then requires all generated object
keys and values to survive recursively; JSON lists compare exactly.
The case count is derived from reachable schema choice points, not supplied by
the caller. An omitted `seed` uses `0`, and `extraCases` appends deterministic
rotation-only cases. No-content,
non-JSON-only, schema-less, and OpenAPI 3.2 `itemSchema` responses throw a loud
`InvalidArgumentException` carrying the structured resolver outcome. Laravel's
`ExploresOpenApiEndpoint::exploreResponseSchema()` resolves the configured spec
and delegates to the same facade; `exploreResponseSpec()` returns the spec-wide
plan. See the [SDK round-trip guide](sdk-roundtrip.md).
## `OpenApiSpecExplorer`
Builds a deterministic whole-spec exploration plan around the existing
single-operation generator:
```php
use Studio\Gesso\Fuzz\OpenApiSpecExplorer;
$summary = OpenApiSpecExplorer::explore('front', casesPerOperation: 20, seed: 1)
->includeTags(['public'])
->excludeOperations(['admin.users.destroy'])
->dispatchUsing(fn ($case, $operation) => dispatch_request($case))
->assertResponseUsing(fn ($response) => assert_contract($response))
->assertResponses();
```
Filters are available for tags, methods, paths, operation IDs, and deprecated
operations. `authenticateUsing()`, `setUpUsing()`, `tearDownUsing()`, and
`mutateCasesUsing()` provide framework/auth/stateful-ID hooks. The returned
`SpecExplorationSummary` exposes executed operation/case counts, the executed
`ExploredOperation` rows (including their coverage keys), and a list of
`ExplorationSkip` entries. See [schema-driven request fuzzing](fuzzing.md).
Each `ExploredCase` exposes `bodyAsJson(): string` for raw HTTP content without
losing empty or nested objects, arrays, scalars, or numeric object keys. Encoding
errors throw `JsonException`; a null value encodes as `'null'`, so omit content
separately for no-body operations or missing-body probes. `bodyAsArray()` remains
a lossy compatibility view, not the recommended HTTP dispatch path.
Call `negativeCases([4])` to switch a whole-spec plan to targeted invalid
inputs and carry explicit expected response classes on each `ExploredCase`.
For one operation, use
`OpenApiEndpointExplorer::exploreInvalid(..., expectedStatusClasses: [4])` or
Laravel's `exploreInvalidEndpoint()`. `FailureReducer::reduce()` minimizes a
failing body while preserving a caller-defined failure classification.
## `OpenApiContractChecks`
Builds a plan of named negative contract checks — probes for protocol-level
holes schema validation cannot see:
| `ContractCheck` case | Probe | Default pass statuses |
|---|---|---|
| `IgnoredAuth` | the valid request without credentials, then with invalid ones | `401`, `403` |
| `MissingRequiredHeader` | the valid request with one `required: true` header omitted, gated behind a 2xx unmutated control request; state-changing methods are skipped unless the dispatcher is registered with `dispatchIsolatedUsing()` | any `4xx` |
| `UnsupportedMethod` | one deterministically chosen undocumented method per documented path | `405` |
```php
use Studio\Gesso\Contract\ContractCheck;
use Studio\Gesso\Contract\OpenApiContractChecks;
$summary = OpenApiContractChecks::run('front', seed: 7)
->checks([ContractCheck::IgnoredAuth, ContractCheck::UnsupportedMethod])
->includeTags(['public'])
->expectedStatuses(ContractCheck::UnsupportedMethod, [405, 404]) // optional override
->expectedStatusClasses(ContractCheck::IgnoredAuth, [4]) // optional override
->dispatchUsing(fn ($case) => dispatch_request($case))
->report();
self::assertSame([], $summary->failures, $summary->describeFailures());
```
The plan shares the exploration filter set (tags, methods, paths, operation
IDs, deprecated) and the `authenticateUsing()` / `setUpUsing()` /
`tearDownUsing()` hooks — except that `IgnoredAuth` probes bypass
`authenticateUsing()` by design. Either `expectedStatuses()` or
`expectedStatusClasses()` replaces a check's whole default expectation.
`dispatchUsing()` may return an `int`, a PSR-7 response, or any object
exposing `getStatusCode(): int`; `dispatchIsolatedUsing()` is the same
registration plus the caller's promise that every dispatch is state-isolated,
which `MissingRequiredHeader` requires before probing a state-changing method. Probes never throw on a status mismatch — the
returned `ContractCheckSummary` collects every `ContractCheckFailure` (naming
the check, operation, dispatched mutation, and a replayable curl command) and
every explained `ContractCheckSkip`, plus `probedPaths` / `dispatchedProbes`
counts. See [named contract checks](contract-checks.md).
## `OpenApiSpecLoader`
Manages spec loading and configuration.
```php
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. `OpenApiRequestValidator` and `OpenApiResponseValidator` record automatically for every result whose path matched the spec, so every entry point — the framework traits, the PSR-7 adapter, and direct validator calls — feeds the tracker without an explicit call. Manual recording remains available for observations that never went through a validator, and every `recordRequest()` / `recordResponse()` call counts as its own observation: the arguments carry no exchange identity, so the tracker never guesses that a manual call refers to an exchange a validator already recorded. Suites still carrying the pre-2.6 quickstart's manual `recordResponse()` after `validate()` therefore report two hits for that exchange — delete the manual call; `validate()` already records. (`hits` is display-and-export only: covered/skipped states and coverage gates are unaffected either way.)
```php
use Studio\Gesso\Coverage\OpenApiCoverageTracker;
// 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,
// ...
// ]
```
`OpenApiCoverageTracker::current()->hasAnyCoverageOn(spec): bool` is a fast presence check; the static `hasAnyCoverage(spec)` wrapper is deprecated since 2.7 and removed in 3.0. `getCovered()` is retained as a diagnostic shim returning `array>`. See [CHANGELOG.md](https://github.com/studio-design/gesso/blob/main/CHANGELOG.md) for the migration from the pre-#111 endpoint-level shape.
---
Source: https://studio-design.github.io/gesso/adr/index.md
# Architecture decision records
Decisions that shape what Gesso is, rather than how one part of it works. A
change that contradicts an accepted ADR needs a new ADR that supersedes it, not
just a pull request.
| ADR | Status | Date | Decision |
| --- | --- | --- | --- |
| [0001 — Align the v2 technical identity with Gesso](0001-gesso-v2-identity.md) | Accepted | 2026-07-13 | Namespace, package, and CLI rename to the Gesso identity, with the scope boundaries the rename did not authorize. |
| [0002 — Phase Arazzo workflow execution behind shared runtime expressions](0002-arazzo-workflow-execution.md) | Accepted | 2026-07-30 | Arazzo support lands in phases, starting from a runtime-expression evaluator shared with the existing validators. |
| [0003 — Branch-complete response payloads and an SDK round-trip harness](0003-sdk-roundtrip-harness.md) | Accepted | 2026-07-31 | Close the SDK ⇄ spec gap by feeding spec-derived payloads through a generated decoder. |
| [0004 — v3 consistency policy and protected core](0004-v3-consistency-policy-and-protected-core.md) | Accepted, amended 2026-08-12 | 2026-08-06 | v3 changes shape, not capability: the invariants that may not regress, the inclusion criterion and its non-goals, and the rule a reduction PR is held to. Amendments: the #508 exception; the sequencing rule that the final v2 minors carry the adopter-visible value while v3.0 deletes; and the condition — not a date — that opens the v3.0 window. |
| [0005 — v3 configuration and CLI naming](0005-v3-configuration-and-cli-naming.md) | Accepted | 2026-08-06 | Resolves the naming conflicts between the four v3 rename issues, and fixes the `gesso.php` key set and the `coverage:merge` flag set they all target. |
| [0006 — Server base paths and request-path matching](0006-server-base-paths-and-request-path-matching.md) | Accepted | 2026-08-07 | `servers[].url` stays out of path matching; `strip_prefixes` remains authoritative, and a failed match names the server base path that would have matched. |
## Writing one
Follow the most recent file. The header carries `Status`, `Date`, `Issue`, and
`Related`; 0004 adds `Supersedes`, which every later ADR should carry even when
the value is `none`. Number files sequentially and add the row above in the same
change, so this page never lags the directory.
---
Source: https://studio-design.github.io/gesso/versioning.md
# Versioning and support policy
This library follows [Semantic Versioning 2.0](https://semver.org/). v1.0.0 is the API stability commitment: anything not marked `@internal` in v1.0.0 is covered by SemVer for the entire v1.x line.
- [What's covered by SemVer](#whats-covered-by-semver)
- [What's NOT covered by SemVer](#whats-not-covered-by-semver)
- [Deprecation policy](#deprecation-policy)
- [Support policy](#support-policy)
- [v2 maintenance lifecycle](#v2-maintenance-lifecycle)
- [v1 maintenance lifecycle](#v1-maintenance-lifecycle)
- [Release checklist](#release-checklist)
## What's covered by SemVer
- Public class names and namespaces (anything not marked `@internal`)
- Public method signatures (parameters, return types, visibility)
- Public constants and their values
- Every non-`@internal` member composed by a public trait, including protected
and private methods, properties, and constants
- Enum cases (additions are minor; removals or renames are major)
- The `OpenApiValidationResult` shape (`outcome()`, `errors()`, `issues()`, `matchedPath()`, `skipReason()`, `isValid()`, `isSkipped()`)
- The `ValidationIssue` shape and its `category` slugs (`request.spec`,
`request.path_match`, `request.method`, `request.parameter.path`,
`request.parameter.query`, `request.parameter.header`, `request.security`,
`request.body`, `response.spec`, `response.request_context`,
`response.status`, `response.body`, `response.header`,
`response.content_type`, and the legacy
fallback `unknown`). New categories may be added in minor releases;
renaming or removing one is major. `instancePath` / `keyword` are populated
on schema violations — for body issues the pointer is into the validated
body, for parameter / response-header issues into the named value — and
`keyword` additionally carries synthetic violation kinds (`required` for a
missing required parameter / header / credential, `format` for a present
but unusable credential); both are `null` for structural and
spec-malformation errors. New `keyword` values may appear in minor
releases. `message` remains explicitly outside the contract (see below).
- The validation JSON document rendered by `JsonValidationResultRenderer`
(`schema_version` 1) — see
[validation-json-schema.md](validation-json-schema.md) for the field-level
rules (additions are minor, removals/renames/type changes are major and
bump `schema_version`).
- The validation failure output selection: the `ValidationOutputFormat` enum
(`text` | `json`), the `ValidationOutput` methods (`format()`, `use()`,
`reset()`), the `GESSO_VALIDATION_FORMAT` environment variable, and the
json-mode failure shape (one header line followed by the versioned JSON
document; for the PSR-7 exchange assertion, one `[request]` / `[response]`
labelled document per failing side).
- The violation baseline file (`baseline_version` 1): the entry fields
(`spec`, `method`, `path`, `status_code`, `content_type`, `category`,
`parameter`, `instance_path`, `keyword`), the fingerprint composition those
fields encode (the human-readable message is deliberately excluded; fixed
HTTP methods are normalized to uppercase while OpenAPI 3.2 custom
`additionalOperations` methods stay case-sensitive; numeric
`instance_path` segments are canonicalized to `*`; non-body issues are
distinguished by the parameter / response-header / security-scheme name in
`parameter`, and parameter / response-header schema violations additionally
by `keyword` and `instance_path` — missing required parameters and headers
carry `keyword: required`, security-scheme satisfaction failures carry
`keyword: required` (credentials absent) or `keyword: format` (present but
unusable), and adapter body-decode failures (unparseable JSON, unreadable
stream) carry `keyword: parse` so they stay distinct from a genuinely empty
body), the `GESSO_BASELINE_GENERATE` environment variable, and the
`baseline_file` / `baseline_stale` extension parameters (`baseline_stale`
values: `off` | `note` | `fail`, default `note`). The enforcement semantics
are part of the contract too: with `baseline_file` configured, a failing
assertion is suppressed only when **every** one of its violations is
baselined, and entries that no longer occur during a full run are reported
as stale per `baseline_stale`. Unknown `baseline_version` values are
rejected.
- The coverage baseline file (`coverage_baseline_version` 1): the
`uncovered_responses` entry fields (`spec`, `method`, `path`, `status`,
`content_type`) and what they identify — one declared response the run did
not validate, at `(method, path, status, content-type)` granularity, where
`status` is the spec response key and `content_type` is `*` for responses
without a `content` block; fixed HTTP methods normalize to uppercase while
OpenAPI 3.2 custom `additionalOperations` methods stay case-sensitive. The
`coverage_baseline_file` / `coverage_baseline_stale` extension parameters
(same values as `baseline_stale`), the matching
`--coverage-baseline-file` / `--coverage-baseline-stale` merge flags, and
the shared `GESSO_BASELINE_GENERATE` generation switch are covered too.
The enforcement semantics are part of the contract: responses that were not
validated — including responses skipped by `skip_response_codes` — are
baselined, an uncovered response absent from the file fails the run, and
entries that are covered now are reported per the stale mode. Unknown
`coverage_baseline_version` values are rejected.
- Named contract checks (`Studio\Gesso\Contract\`; the pre-2.7
`Studio\Gesso\Fuzz\` spellings are deprecated aliases removed in v3.0):
`ContractCheck` names and default expected statuses and status classes, the
`OpenApiContractChecks`/`ContractCheckPlan` fluent surface, and the
`ContractCheckSummary`/`ContractCheckFailure`/`ContractCheckSkip` shapes.
Failure/skip prose is not a contract, and neither is the placeholder value
an `ignored_auth` invalid-credential probe writes.
- CLI surfaces by major (commands, flags, exit codes, and versioned inputs and
output where applicable):
- v1.x: `bin/openapi-contract`, `bin/openapi-coverage-merge`, and the v1.10
`bin/gesso` entry point
- v2.x: the `doctor`, `coverage:merge`, `coverage:gate`, and `stubs`
subcommands of `bin/gesso`; the legacy standalone binaries are not shipped
- The Laravel `gesso:routes` and `gesso:stubs` command surfaces (flags, exit codes, and versioned JSON output)
- The four environment variables Gesso owns — `GESSO_VALIDATION_FORMAT`,
`GESSO_CONSOLE_OUTPUT`, `GESSO_BASELINE_GENERATE`, and `GESSO_SIDECAR_TOKEN`
— and the values each accepts. `TEST_TOKEN` and `GITHUB_STEP_SUMMARY` are
read but belong to paratest and GitHub Actions; they are not Gesso's to
version.
- The `OpenApiCoverageExtension` PHPUnit configuration parameters (`spec_base_path`, `strip_prefixes`, `specs`, `output_file`, `console_output`, `validation_output`, `baseline_file`, `coverage_baseline_file`, `strict_required`, `strict_additional_properties`, `strict_additional_properties_per_call`, …)
- The shared `gesso.php` keys and their types, the PHPUnit `config` parameter,
and the Laravel `gesso` config keys documented in
[Shared configuration](configuration.md). The additive v2 bridge preserves
existing inputs and their precedence; their removal requires a major release
and the deprecation process below.
- The Laravel `ValidatesOpenApiSchema` trait's public methods
- The category prefixes used in `E_USER_WARNING` messages (`[security]`, `[OpenAPI Schema]`, and the `[OpenAPI 3.2 ...]` categories) and in `E_USER_DEPRECATED` messages (`[Gesso deprecation]`). Adding a category is a minor change; renaming or removing one is major.
### Versioned sidecar compatibility
The PHP methods that produce and consume sidecar state (`exportState()` and
`importState()`) are `@internal`; their signatures are not a public PHP API.
The persisted payloads accepted by the released merge CLI are nevertheless a
compatibility surface because workers and the merge step can run different
installed versions during an upgrade.
The v1.9 writer emits a sidecar envelope with `envelopeVersion: 2`, containing
coverage state `version: 1` and strict-required state `version: 2`. The v1.9
reader accepts that envelope and the legacy bare coverage state `version: 1`.
It recognises `part-*.json` sidecars and `failed-*.json` failure markers.
The current writer emits `envelopeVersion: 10`. `envelopeVersion` and
`coverage` are required; every other key is optional, and its absence means
the writer had no such tracker. A new tracker half is a new optional key, not
a new envelope version.
| Key | Inner version owned by |
| --- | --- |
| `coverage` (required) | coverage state `version: 1` |
| `strictRequired` | strict-required state `version: 2` |
| `strictAdditionalProperties` | strict-additional-properties state `version: 1` |
| `sdkExercise` | SDK exercise state `version: 1` |
| `deprecations` | deprecation state `version: 1` |
| `baseline` | violation-baseline document `baseline_version: 1` (generation runs only) |
The 2.x reader also accepts the legacy envelopes 2–9 written by Gesso 1.9–2.6
workers, and the legacy bare coverage state. In those envelopes the version
number enumerated the halves: 3/5/7/9 carry `baseline` and their even
sibling must not; 4–9 carry strict-additional-properties state, 6–9 SDK
exercise state, and 8/9 deprecation state. Those rules still apply to legacy
inputs, and a half that a legacy version does not carry reads as absent.
Absence is reported, never treated as "this worker recorded none". The merge
says its deprecation count is a lower bound when a sidecar has no deprecation
half; `coverage:merge --baseline-file` refuses to write a union when any
sidecar lacks the baseline half; a strict parallel SDK exercise threshold
rejects any worker sidecar without the SDK half, while warn-only SDK gating
reports the missing-worker count and evaluates the available observations.
`--strict-additional-properties=fail` fails loudly when any worker lacks that
state, or when no worker exported an evaluation, instead of treating an
incomplete/old-fleet merge as clean. Unknown envelope versions and unknown
inner coverage, strict, baseline, or SDK tracker versions are rejected; they
are never guessed or partially imported.
The compatibility rules are:
- A newer merge reader keeps support for the explicitly documented older
payloads within the same major line.
- A format owner bumps its version for an incompatible shape change. Envelope,
coverage state, strict-required state, strict-additional-properties state,
and SDK exercise state versions evolve independently.
- An older reader is not required to accept payloads written by a future
version. Unknown versions and unrecognised shapes fail loudly instead of
being guessed or partially merged.
- A legacy payload may be rejected when accepting it would silently lose data.
In particular, strict-required state `version: 1` is not accepted by the
v1.9 reader because it cannot represent nested pointer observations from
`version: 2`.
For the Gesso v2 migration, the v2 merge reader will retain the v1.9 inputs
listed above. Any later major-version cutoff must be called out in that
version's upgrade guide; it must not appear as an unversioned shape change.
These rules apply to the worker-to-merge protocol only. The coverage report
written by `json_output` has its own `schema_version` contract documented in
[`coverage-json-schema.md`](coverage-json-schema.md).
## What's NOT covered by SemVer
- Anything marked `@internal` — including the `Internal\` and `Validation\Support\` namespaces, the per-validator helpers under `Validation\Request\` / `Validation\Response\`, `Spec\OpenApiSchemaConverter` / `Spec\OpenApiPathMatcher` / `Spec\OpenApiRefResolver` / `Spec\OpenApiPathSuggester`, the PHPUnit `CoverageReportSubscriber`, `Cli\DoctorCommand`, `Cli\CoverageGateCommand`, `Cli\StubsCommand`, the `Stubs\` namespace, `Coverage\CoverageMergeCommand` (the `gesso doctor`, `gesso coverage:gate`, `gesso coverage:merge`, and `gesso stubs` CLI surfaces remain covered — these classes are their implementation details), and test seams (`*::resetWarningStateForTesting()`, `OpenApiSpecLoader::reset()`, `OpenApiCoverageTracker::reset()` / `exportState()` / `importState()`).
- Validator error message wording (we may improve them; assert on presence/category, not on exact strings).
- The text-mode assertion failure layout — the header line, the error list,
and the trailing `Reproduce:` curl line (including its redaction details).
Machine consumers should select the json output mode, whose failure shape
and document schema are covered above.
- The set of `format` keywords delegated to opis — we follow opis upstream, so a new format is added when opis adds it.
- Behaviour of bug-fix releases that close a documented silent-pass case. A test that passed only because of the silent pass may start failing — that's the fix doing its job, not a SemVer break.
`@internal` is enforced statically. Our CI runs PHPStan (2.1.13 or newer; `composer.json` pins an exact version that Renovate bumps) with the `bleedingEdge` ruleset enabled so that `new.internalClass` / `method.internalClass` / `staticMethod.internalClass` / `return.internalClass` / `parameter.internalClass` / `classConstant.internalClass` / `catch.internalClass` violations fail the build. The boundary is the **root namespace** — any code outside `Studio\` that instantiates, calls, type-hints against, or accesses constants on an `@internal` symbol will surface as a PHPStan error. Downstream consumers who enable bleedingEdge in their own PHPStan setup get the same enforcement automatically. Inheritance (`extends`/`implements`) of `@internal` classes is **not** enforced by these rules — that ships under a separate bleedingEdge rule we have not opted into yet. The `bin/gesso` CLI script is the only place inside this repository that crosses the boundary by design (it lives in the global namespace and instantiates `Cli\DoctorCommand` and `Coverage\CoverageMergeCommand`); it is excluded from PHPStan's `paths` so it does not pollute the analysis.
See [UPGRADING.md](https://github.com/studio-design/gesso/blob/main/UPGRADING.md) for migration notes between versions.
## Deprecation policy
SemVer requires a minor bump when public API is deprecated and a major bump for
its incompatible removal. This project adds one standing rule on top:
> A surface covered by this document is removed or renamed in a major release
> only if a deprecation for it shipped in a minor release of the preceding
> major. The last minor of a major is therefore the last chance to deprecate
> anything for the next one.
The rule covers every surface listed under "What's covered by SemVer" — not
just PHP symbols, but configuration keys, CLI flags, environment variables, and
Artisan command names. It does not apply to `@internal` symbols or to anything
in the "NOT covered by SemVer" list.
Deprecations are announced at runtime, not only in a docblock. Each one emits a
one-shot `E_USER_DEPRECATED` carrying the `[Gesso deprecation]` prefix, the
replacement, and the removal version; the PHPUnit extension writes a single
end-of-run STDERR line counting how many deprecated surfaces the suite still
uses. Under paratest that line comes from `gesso coverage:merge`, summed over
the worker sidecars. See [diagnostic
channels](supported-features.md#diagnostic-channels-e_user_warning-and-e_user_deprecated).
**If a run on the final minor of a major reports zero Gesso deprecations,
upgrading to the next major requires no consumer code, configuration, or CLI
changes.** That is what the rule buys: the deprecation report, not the upgrade
guide, is the authoritative answer to "am I ready?".
For the v2 → v3 transition, the deprecation-carrying release is the final v2
minor. Its number is not chosen by hand — release-please derives it from
Conventional Commit types — so the constraint is an ordering one: every v2
release must ship before the first breaking commit reaches `main`, because that
commit turns the pending release into `3.0.0` and no further v2 release can be
cut from the branch.
### The rename checklist
Two fixtures carry the v2 → v3 transition, and they are checked from opposite
directions because each one is blind to the other's failure mode.
| Fixture | Scanned from | Catches |
| --- | --- | --- |
| `tests/fixtures/compatibility/v2-deprecations.json` | `src/` and `bin/`, by `DeprecationRegistryTest` | a `Deprecations::notice()` call nobody registered, and a notice re-pointed, re-dated, or moved to another surface without the ledger saying so |
| `tests/fixtures/compatibility/v3-renames.json` | [ADR 0005](adr/0005-v3-configuration-and-cli-naming.md), by `V3RenameRegistryTest` | a rename that ships no notice at all |
The first cannot see the second. A rename that simply never calls
`Deprecations::notice()` emits nothing, so the scan finds nothing and the
registry stays as correct — and as empty — as it was.
`Deprecations` is the only place allowed to put a notice on the
`E_USER_DEPRECATED` channel, and that is enforced rather than asked for: a
`trigger_error(..., E_USER_DEPRECATED)` — or `user_error()`, PHP's own alias —
or a `#[\Deprecated]` attribute anywhere in `src/` or `bin/` fails the scan,
because it announces a removal the registry has no row for. The severity is
read as a value, in every base PHP writes one, so `0x4000` is the same thing
spelled differently; only the four severities `trigger_error()` accepts can
answer "not the deprecation channel", and anything else counts as the channel
rather than being waved through. The one exception — the Laravel
adapter's contradictory-intent warning, which rides the channel so PHPUnit
counts it and announces no removal — is listed by name and by call count in
`DeprecationRegistryTest`.
Reaching the emitter in a way the scan cannot read fails for the same reason:
call it by name. A literal `Deprecations::notice(...)` is the only form the
scan can attach a registry entry to, so a computed class, a computed member
(`::{'notice'}`, `::${'notice'}`, `::$method(`), a `::class` callable, and a
string naming the emitter are all reported instead. A string is compared as
PHP builds it rather than as it is typed — quoted, heredoc, or nowdoc, each
with the escapes it applies — so `\x53tudio\Gesso\…` is that name too.
Each registry entry carries the notice twice: once under `notice`, as the three
prose arguments the call passes, compared to `src/` argument by argument; and
once as the bare `spelling` and `v3_target` the tests compare by equality.
Prose alone cannot carry the link, because one sentence can name two spellings
and an id then points at whichever of them the reader assumes. **Changing the
wording, the target, or the removal version of a notice is therefore a change
to this fixture too** — which is the point, since the fixture is the list a
major deletes from.
`v3-renames.json` therefore starts from the ADR: it lists every old spelling
ADR 0005's two tables name, the v3 spelling that replaces it, the channel it
uses, and the deprecation id once one is staged. Each replacement is compared
byte-for-byte against the right of the matching `→` in the ADR, which is why
a row replacing several spellings at once writes one `→` per spelling —
otherwise the fixture could name the wrong member of the right key.
**A PR that renames a configuration key or a CLI flag updates this fixture in
the same change.** Its `unstaged_count` is a ratchet —
staging a deprecation lowers it, and the test fails in both directions, so
neither an unstaged addition nor a stale number can sit unnoticed. Zero means
every v3 rename has shipped its notice and the final v2 minor can be cut.
Two numbers bounding the fixture live in `V3RenameRegistryTest` rather than in
the fixture: how many spellings ADR 0005 names, and the highest
`unstaged_count` allowed. A number a file keeps about itself bounds nothing —
lowering either is progress and costs one line, raising one is a decision and
shows up in the diff of a test.
`owner` is derived, not declared: ADR 0005's rule 4 gives each name one issue,
and the test applies its own division of labour — the environment variables and
Artisan commands to #504, the CLI to #507, the keys that collapse several v2
settings to #502, the rest of the key set to #501. A row that the rule would
get wrong names its issue inline instead, the way the removals carry `(#508)`.
The three channels a spelling can take:
- **`deprecation`** — the spelling stops working. `E_USER_DEPRECATED` through
`Studio\Gesso\Internal\Deprecations`, with an entry in `v2-deprecations.json`.
- **`accepted-spelling`** — the spelling keeps working. The `[Gesso]` warning
channel through `Studio\Gesso\Internal\LegacyIdentity`, described below.
- **`unchanged-spelling`** — nothing is removed; listed only so the gate can
account for every ADR row. This is the one channel that stages nothing and
counts toward nothing, so membership is a hand-written list in
`V3RenameRegistryTest`, not a property of the entry. Keeping a *name* is not
enough: `baseline_stale` and the two `--strict-*` flags keep theirs while
replacing the value they accept, which is a removal to anyone who wrote the
old value down, so they take the `deprecation` channel.
### Renamed spellings still accepted
A rename that keeps the old spelling working is not a removal, so it does not go
through the `E_USER_DEPRECATED` channel above: a suite running PHPUnit's
`failOnDeprecation` would fail for spelling a name that still works. These names
warn once per process on STDERR instead, under the `[Gesso]` prefix, and each
warning names its replacement and its removal version.
| Accepted spelling | Use instead | Accepted through | Removed in |
| --- | --- | --- | --- |
| `OPENAPI_VALIDATION_OUTPUT` | `GESSO_VALIDATION_FORMAT` | v3.x | v4.0 |
| `OPENAPI_CONSOLE_OUTPUT` | `GESSO_CONSOLE_OUTPUT` | v3.x | v4.0 |
| `OPENAPI_BASELINE_GENERATE` | `GESSO_BASELINE_GENERATE` | v3.x | v4.0 |
| `openapi:routes` | `gesso:routes` | v3.x | v4.0 |
| `openapi:stubs` | `gesso:stubs` | v3.x | v4.0 |
The current spelling wins when both are set, and setting only the accepted one
resolves to the same value with no behaviour difference. The map lives in
`Studio\Gesso\Internal\LegacyIdentity`; its removal is tracked by
[#523](https://github.com/studio-design/gesso/issues/523).
## Support policy
| Component | Supported |
| --- | --- |
| PHP runtime | v2.x — CI: 8.3, 8.4, 8.5; Composer: `^8.3`. v1.x — CI: 8.2, 8.3, 8.4; Composer: `^8.2`, subject to the lifecycle below. |
| PHPUnit | v2.x: 12.x, 13.x. v1.x: 11.x, 12.x, 13.x. Each line is covered by its branch CI matrix. |
| `opis/json-schema` | `^2.6` for v1.x and v2.x. A jump to `^3` would be a SemVer-major. |
| Laravel (optional adapter) | Whatever `orchestra/testbench` `^9 \|\| ^10 \|\| ^11` supports. |
After v2 stable, v2 bug fixes and security updates land on its latest minor.
Each line follows its maintenance lifecycle below. Neither major maintains
older minor branches; upgrade to the latest minor of the selected major to
receive fixes.
## v2 maintenance lifecycle
v3.0 has no scheduled date: its window opens on a condition rather than a
calendar deadline, recorded in
[ADR 0004](adr/0004-v3-consistency-policy-and-protected-core.md#2026-08-12--the-v30-window-opens-on-a-condition-not-a-date).
This line's support is therefore anchored to the v3.0.0 release rather than to
fixed dates.
| Period | Ends | Accepted changes |
| --- | --- | --- |
| Active maintenance | 12 months after v3.0.0 is released, and no earlier than 2028-07-01 | Security fixes, backward-compatible bug fixes, and critical ecosystem interoperability fixes |
| Security maintenance | 6 months after active maintenance ends | Security fixes only; non-security changes require evidence that they are necessary to ship a security fix safely |
| End of life | when security maintenance ends | No releases or support commitment |
**Adopting v2 today does not carry an unknown support horizon.** The earliest
v3.0.0 can be released is 2027-07-01, because the ADR 0004 window cannot open
before v1 reaches end of life. Twelve months after that is 2028-07-01, so that
is the earliest date active maintenance can end, and 2029-01-01 the earliest
end of life.
The 2028-07-01 floor in the table restates that derived date as a commitment
of this document. It is not there to bind the relative rule — it cannot, since
the two are equal by construction today — but to make the guarantee
independent: shortening it requires changing this table, so amending ADR 0004
cannot quietly shorten how long v2 is supported.
The window is longer than the one v1 received — v1 entered security
maintenance about five months after v2.0.0 shipped — and the difference is
deliberate. v1 was the pre-rename line under a package that is now
[marked abandoned][composer-abandoned], and its migration path is a package
swap documented in [migration/v2.md](migration/v2.md). v2 is the line adopters
are asked to build on.
The anchor is relative because a calendar one decouples from the release it
describes: the v1 table below carries a note that its dates do not move
automatically if the v2 schedule changes, which is a manual obligation this
line avoids by construction. When v3.0.0 ships, the resolved dates are
recorded in this section by the same PR that closes the v3 window.
## v1 maintenance lifecycle
v1.10.0 is the final planned feature minor of the original
`studio-design/openapi-contract-testing` package. It may add backward-compatible
migration aids and deprecations for Gesso 2.0. Under the [deprecation
policy](#deprecation-policy), v1.10.0 is the last release that can carry a
deprecation for a surface v2 removes. After v1.10.0, the v1 line receives
patches from the `1.x` maintenance branch:
V1.10.0 also provides lazy `Studio\Gesso\` aliases for all non-`@internal`
public PHP types. Consumers should use these aliases to migrate imports before
changing Composer packages. The aliases do not cover configuration, wire
formats, or literal class-name identity, and v2 does not provide a reverse
legacy namespace shim. V1.10 adds `gesso doctor` and `gesso coverage:merge` so
command invocations can move before the package switch; the v1 standalone
binaries remain supported until v1 EOL and are removed in v2. Follow the
[staged v2 migration guide](migration/v2.md) for these boundaries.
| Period | Dates | Accepted changes |
| --- | --- | --- |
| Active maintenance | through 2026-12-31 | Security fixes, backward-compatible bug fixes, and critical ecosystem interoperability fixes |
| Security maintenance | 2027-01-01 through 2027-06-30 | Security fixes only; non-security changes require evidence that they are necessary to ship a security fix safely |
| End of life | from 2027-07-01 | No releases or support commitment; migrate to `studio-design/gesso` |
These dates are calendar deadlines in UTC and do not move automatically if the
v2 schedule changes. If v2 stable is not available before active maintenance
ends, the maintainers must publish a revised calendar before 2026-12-31 rather
than silently extending or shortening support.
The v1 Composer PHP constraint remains `^8.2`; a patch release does not raise
the declared floor. [PHP 8.2 reaches upstream security EOL][php-support] on
2026-12-31.
Compatibility checks may continue during v1 security maintenance, but Gesso
cannot provide fixes for vulnerabilities in an EOL PHP runtime. Consumers
should run v1 on a PHP branch still supported by the PHP project.
The `1.x` branch is created from the v1.10.0 release commit only after its tag
and GitHub release have been produced by release-please. `main` then becomes the
v2 development line. Both branches use release-please independently; v1 patch
tags remain `v1.10.z` and v2 tags use `v2.y.z`.
Changes that affect both majors land on `main` first, then are cherry-picked
from the squashed commit into a new branch based on `1.x` and reviewed in a
separate PR targeting `1.x`. A v1-only compatibility or security fix may target
`1.x` directly when no equivalent v2 change is needed. Never merge `main` into
`1.x`, and never combine unrelated backports. The maintenance PR must preserve
the v1 public compatibility surface and pass the same required checks as a
`main` PR.
The old Composer package remains installable throughout this lifecycle. It is
[marked abandoned][composer-abandoned] with `studio-design/gesso` as its
suggested replacement only after the v2 stable package is installable and its
migration path has been verified. Abandonment is a migration signal, not
permission to remove existing v1 tags. The package remains supported according
to the dates above even if the abandonment notice is added earlier.
[composer-abandoned]: https://getcomposer.org/doc/04-schema.md#abandoned
[php-support]: https://www.php.net/supported-versions.php
## Release checklist
Before publishing a release:
- [ ] Run the supported PHP / PHPUnit / framework matrix in CI.
- [ ] Review `UPGRADING.md` and the generated release notes for SemVer accuracy.
- [ ] For a v2 beta or stable promotion, complete the tag, GitHub Release,
release manifest, Packagist metadata, clean-install, and published-artifact
checks in the
[Gesso 2.0 release procedure][v2-release-procedure].
- [ ] If the README feature comparison was last checked three or more months ago, verify every competitor version and linked capability against its tagged official README and `composer.json`, update the checked date, and keep competitor strengths as well as gaps.
[v2-release-procedure]: https://github.com/studio-design/gesso/blob/main/CONTRIBUTING.md#gesso-20-beta-and-stable-promotion