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.
- OpenAPI 3.0, 3.1, and 3.2
- Body validation
- Parameter styles
- Security schemes
- Schema features
- HTTP methods
- Spec features not consulted
- Warning channel (
E_USER_WARNINGcontract)
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. - Presence-only (no schema validation): every other media type, including
application/xml,multipart/form-data,application/x-www-form-urlencoded,text/plain, andapplication/octet-stream. 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. - Multipart
encodingobject: per-partcontentType/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: only
style: form+explode: true(the OAS default). Specs declaringpipeDelimited,spaceDelimited,deepObject, orform+explode: falseare 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. 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. - 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), 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.
Warning channel (E_USER_WARNING contract)
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.
| Category prefix | Source | Dedup key |
|---|---|---|
[security] | SecurityValidator (oauth2, openIdConnect, mutualTLS, http-basic, http-digest) | scheme name |
[OpenAPI Schema] | OpenApiSchemaConverter (3.0-only unevaluated* / dependent*, unknown / malformed format) | per-keyword / per-format-value |
[OpenAPI 3.2 querystring] | QueryParameterValidator (serialized query media type cannot be reconstructed) | declared media-type set |
[OpenAPI 3.2 discriminator] | OpenApiSchemaConverter (defaultMapping with implicit mappings only) | process-wide limitation key |
[OpenAPI 3.2 $self] | OpenApiSpecLoader ($self base URI is not applied) | spec load/cache |
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 }); - Suppress one category (e.g. acknowledged limitation): 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.