Documentation version: nextdevelopment documentation
Skip to content

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 for the current results.

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.

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:

Feature3.0 handling3.1 / 3.2 handling
nullable: trueConverted to type array ["string", "null"]; null appended to enum if presentNot applicable (uses type arrays natively)
Boolean exclusiveMinimum / exclusiveMaximumLowered with its numeric minimum / maximum to the Draft 07 numeric formNative numeric JSON Schema keyword
prefixItemsN/APreserved and enforced natively by JSON Schema 2020-12
$dynamicRef / $dynamicAnchorN/APreserved and enforced natively
examples (array)Removed (Draft 2020-12 keyword, not Draft 07)Removed (Draft 2020-12 keyword, not Draft 07)
constN/APreserved and enforced natively
readOnly / writeOnlySemantic enforcement (see below). Forbidden properties become boolean false subschemas; the keyword is dropped as OAS-only on surviving propertiesSemantic 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 and 3.1-to-3.2 upgrade guide. 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

  • Validated: application/json and any +json structured-syntax suffix (RFC 6838), and content keys using ranges (application/*, */*) — the matcher tries exact match first, then <type>/*, 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. 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.<part>.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.<part>.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 falserequired 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 <parameter name="enforce_discriminator" value="false"/> (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).

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), 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 prefixLevelSourceDedup key
[security]E_USER_WARNINGSecurityValidator (oauth2, openIdConnect, mutualTLS, http-basic, http-digest)scheme name
[OpenAPI Schema]E_USER_WARNINGOpenApiSchemaConverter (3.0-only unevaluated* / dependent*, unknown / malformed format)per-keyword / per-format-value
[OpenAPI 3.2 querystring]E_USER_WARNINGQueryParameterValidator (serialized query media type cannot be reconstructed)declared media-type set
[OpenAPI 3.2 discriminator]E_USER_WARNINGOpenApiSchemaConverter (defaultMapping with implicit mappings only)process-wide limitation key
[OpenAPI 3.2 $self]E_USER_WARNINGOpenApiSpecLoader ($self base URI is not applied)spec load/cache
[Gesso deprecation]E_USER_DEPRECATEDInternal\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.

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_DEPRECATEDfailOnDeprecation 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 for the current list and versioning 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. 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_WARNINGs — 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 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.