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
- Body validation
- Parameter styles
- Security schemes
- Schema features
- HTTP methods
- Spec features not consulted
- Diagnostic channels (
E_USER_WARNINGandE_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.
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 and 3.1-to-3.2 upgrade guide. The contract-relevant 3.2 additions have explicit behavior:
QUERYworks in direct validators, PSR-7, Laravel, Symfony, Pest, fuzz exploration, and coverage reports.- Custom methods under
additionalOperationsresolve 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 acceptQUERYbut not arbitrary custom method tokens; whole-spec exploration reports those operations as skipped with a reason instead of silently omitting them. - One
in: querystringparameter withapplication/x-www-form-urlencodedcontent validates the entire framework-parsed query map against its schema. Mixing it within: 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.defaultMappingis enforced for missing and unknown values when an explicitmappingis also present. With implicit mappings only, missing values use the fallback while unknown present values rely on the underlyingoneOf/anyOf;[OpenAPI 3.2 discriminator]makes that residual limitation observable.itemSchemastreaming bodies are returned asSkippedwith 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
$selfemits[OpenAPI 3.2 $self]: relative references still resolve from the retrieved file path, so specs depending on a different$selfbase 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 markedwriteOnly: truemust not appear in the response body. If it does, validation fails with the offending property named in the error. AwriteOnly + requiredentry is treated as absent on the response side, so a compliant response that omits the property still validates. - Request validation (
OpenApiRequestValidator): any property markedreadOnly: truemust not appear in the request body.readOnly + requiredis treated as absent on the request side, so a compliant request that omits the property still validates.
Detection looks at each property schema's own top-level readOnly / writeOnly; markers nested inside the property's allOf / oneOf / anyOf children are not enforced in the current release.
Body validation
- Validated:
application/jsonand any+jsonstructured-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+jsonfor 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 asapplication/problem+jsonis judged against its own schema, not the success-shapeapplication/jsonschema. Vendor+jsonsuffixes 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 aNote: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 aresponse.content_typeissue 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, andapplication/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 aschema(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 asSkippedwith askipReasonso the unvalidated body is surfaced in coverage rather than counted as a clean pass. A non-JSON entry with noschemahas nothing to validate and stays a plain success. - OpenAPI 3.2 streaming
itemSchema: explicitlySkipped, never counted as a clean validation.prefixEncoding/itemEncodingare 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, soage=3satisfiestype: integerwhileage=threefails at/age. Framework adapters hand the parsed field map to the validator (the PSR-7 adapter uses aServerRequest's parsed body and uploaded files; a clientRequestInterfacecarrying raw urlencoded bytes is parsed by the validator). A rawmultipart/form-datapayload with no parsed parts is not reassembled — it staysSkippedwith 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 aminLength/patternon a binary property is measured against the filename, not the file), and its declaredContent-Typeis checked against theencodingobject. Per RFC 7578 §4.4 a part with no Content-Type of its own counts astext/plainrather than matching anything. A file whose upload failed (anything other thanUPLOAD_ERR_OK— no file sent, size limit, partial write) is dropped before validation, so it cannot satisfy arequiredpart, 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'scontentMediaTypewith notype(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 exampletype: array, items: {}a list of files. A declaredtyperules it out: per JSON Schema 2020-12type: stringwith acontentMediaTypeand nocontentEncodingis identity-encoded UTF-8 text, so it is validated as an ordinary field whatever the media type says.format: byteand an explicitcontentEncodingare text on the wire too. - Multipart
encodingobject:encoding.<part>.contentTypeis 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); whencontentTypeis omitted the default is computed from the property type alone —application/jsonfortype: object, the inner type's default for an array,application/octet-streamfor aformat: binarystring or for any schema that declares notype,text/plainfor the other primitives. NeithercontentMediaTypenor an untypedpropertiesblock 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 asapplication/json, application/xml, or a single non-text type such asimage/pngon a plain field — the body is returned asSkippedwith 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, sinceencodingapplies to its items. That keeps every object-level constraint (required,minProperties,additionalProperties, a composedrequired) honest, while anif/oneOf/dependentRequiredbranch 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, plusallOf/dependentSchemasreduced the same way) and validated against the same data. What it still reports — an unconditionalrequired,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$refthe loader did not inline leaves nothing provably unconditional, so everything is left unconfirmed then.encoding.<part>.headers/style/explodeare not consulted. - Cascading
additionalProperties: falseerrors are stripped automatically. opis'sPropertiesKeywordskips itsaddCheckedProperties()call whenever any sub-property fails its schema, leaving$checkedempty in the validation context. The follow-onadditionalProperties: falsekeyword then reports every property the data carries — including ones explicitly declared in the schema'sproperties— as "additional". The validator walks opis'sValidationErrortree, reads the raw list of "additional" property names fromargs()['properties'], and filters out names that ARE declared in the schema'spropertieskeyword 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-schemaitems, Draft 07 tuple-formitems, and native 2020-12prefixItems. 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. Fortype: arrayschemas the non-exploded serializations are split on their delimiter before validation:form+explode: falseon,,pipeDelimitedon|/%7C(the OAS Style Examples percent-encode this delimiter),spaceDelimitedon%20/+. The framework adapters (PSR-7, Laravel, Symfony) pass the raw query string through, so splitting happens before percent-decoding and a%2Cinside aform-style value stays data (role=owner%2Cadmin,member→["owner,admin", "member"]); a delimiter character inside apipeDelimited/spaceDelimitedvalue 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). DirectOpenApiRequestValidatorcallers get the same by passingrawQueryString, 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.deepObjectand non-explodedtype: objectparameters are not parsed; type-mismatch errors will surface but they will point at the wrong cause. - Query string (3.2):
in: querystringwithapplication/x-www-form-urlencodedvalidates the whole parsed query map. Other media types emit a categorized warning and skip query-string validation. - Header / Path: only
style: simplefor scalar values.type: arrayandtype: objectparameters are not parsed (the raw string is fed to the schema, which then mismatches).style: matrixandstyle: labelfor path parameters are not handled — the prefix is not stripped before validation. - Cookie parameters (
apiKeysecurity scheme aside): not validated. parameters[].content: read only for OpenAPI 3.2in: querystring; other parameter locations still useparameters[].schemaonly.
Security schemes
Validated:
apiKey(inheader/query/cookie) andhttp+bearer— presence checks for the named header/query/cookie / RFC 6750Bearertoken.Loud
E_USER_WARNINGon first encounter:oauth2,openIdConnect,mutualTLS, andhttpschemes other thanbearer(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.xmlfailOnWarning="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, anddependentRequired. These are preserved and delegated to the selected JSON Schema dialect rather than lowered or discarded. $refsibling keywords (#536): where the effective JSON Schema dialect is 2019-09 or later — the OpenAPI 3.1/3.2 default — a Schema Object$refis 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 asallOfbranches. 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 readtypeanditems, form decoding readsproperties,unevaluatedProperties/unevaluatedItemsread the annotations of adjacent keywords, andreadOnly/writeOnlyare 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 schemasfalseandtruetreated as the absorbing and identity elements they are, so a siblingtruenever re-opens a property the target closed withfalse—requiredunions, 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], apropertiesor a subschema written as a JSON array, an empty or non-schemaallOf, a$schemathat 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 aniffrom itsthen, or anunevaluatedPropertiesfrom thepropertiesit reads, changes what the schema means. When a collision has no meaning-preserving merge, the siblings are applied whole as an adjacentallOfbranch, leaving the target's own top level untouched. The same fallback covers the one interaction that is not a collision:additionalPropertiesapplies to the names its own adjacentpropertiesdo 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 withadditionalProperties: falsethat turns a schema nothing can satisfy into one that accepts the union. (unevaluatedPropertiesis different: it reads the annotations of adjacent in-place applicators,$refamong them, so the flat merge is exactly what it already meant.) A target that declares its own$schemais 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 (itemsvsprefixItems). Coercion follows through the branch —TypeCoercerreadstypeanditemsthroughallOf, and becauseallOfANDs, the type it coerces to is the intersection of the declared type sets (withintegertreated as the subset ofnumberit is, so a union offering both coerces asnumber) and the item schema it coerces against is the conjunction of everyitemsthat applies, rather than whichever one sits at the top level. The same resource rule applies to a referenced external document — its own root$schema(orjsonSchemaDialect) decides whether$refsiblings 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$schemadeclaration is written onto the resolved schema — without it a Draft 07 tupleitems: [ … ]pulled into a 2020-12 document would be read as the single-schema 2020-12itemsand rejected. It is re-attached verbatim: a$schemanaming no dialect this package reads — a non-string value, or an unsupported URI — selects none, so nothing inside that resource applies$refsiblings or claims a dialect of its own, and the declaration always travels with the target: onto it, onto anallOfwrapper 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 anyx-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$refobject 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 throughjsonSchemaDialector$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 authoritativeKNOWN_OPIS_FORMATSconstant insrc/Spec/OpenApiSchemaConverter.php— keeping it in one place avoids drift when opis adds formats. Unknown values (e.g.format: emialtypo foremail) emit a one-shotE_USER_WARNINGper format value, since opis silently accepts any value for unrecognised formats. Non-stringformatvalues 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; seeADVISORY_FORMATSconstant. - Empty Schema Object
{}(#478): a{}in a schema position —properties: {x: {}},additionalProperties: {},items: {},not: {},if/then/else,contains,propertyNames,patternPropertiesvalues,dependencies/dependentSchemasvalues,$defsvalues — means "any value" and is normalised to the equivalent boolean schematrue, because specs are decoded withjson_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: []andprefixItems: []validate as written, andenum: []/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→ anallOfofif/thenconditionals (default; seediscriminatorbelow). OpenAPI 3.0nullableis lowered for Draft 07 compatibility. - Stripped:
xml,externalDocs,example/examples,deprecated, and OAS-onlynullable/readOnly/writeOnlyafter enforcement (3.0).discriminatoris 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, anddependentRequiredstill emitE_USER_WARNINGwhen 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 declaresdiscriminatorwith a non-emptymapping, the converter lowers it into anallOfof an unknown-value guard (the discriminator property must be present and one of the mapping keys) plus oneif/thenper mapping value, wherethenis the resolved subtype schema. The discriminator value therefore steers validation toward a single branch — a body that lies about its type (e.g.kty: RSAcarrying EC-only fields) fails instead of passing the underlyingoneOf/anyOfunion. 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. NoE_USER_WARNINGis emitted.- Opt out: set
enforce_discriminator: false(Laravel config) or<parameter name="enforce_discriminator" value="false"/>(the PHPUnit extension;0/noalso work) to restore the historical behaviour —discriminatoris stripped and the mapping is not enforced (and no warning is emitted). - Malformed blocks: with enforcement on, a structurally invalid
discriminator(missing/non-stringpropertyName, non-arraymapping, 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$refresolution; 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 outerthen. nullable+discriminator(3.0): anullbody fails the discriminated-object branch (the lowered guard requires the discriminator property). Model a null-tolerant polymorphic field with an explicitoneOfincluding{type: 'null'}if needed.
- Opt out: set
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 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.
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 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 isfalse). 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-scopedacknowledged_unvalidatable_schemessetting (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.