Limitations
The generator targets the common case: object schemas, $refs, allOf composition,
additionalProperties maps, enums, arrays, and scalar constraints, validated against 135 real-world
specs. A few OpenAPI features are not yet fully represented. Even degraded constructs produce code
that parses and compiles, these are completeness gaps, not crashes, but for a schema that relies on
them the generated class can be incomplete. This page is honest about where that happens so you can plan around it.
None of it is silent: every degradation to mixed or to a raw Illuminate\Http\Request /
JsonResponse fallback emits a build warning to stderr at generation time, naming the schema or
operation and (for $ref causes) the offending pointer, one warning per distinct cause and
location. The only mixed-typed cases that do not warn are the deliberate, visible ones called
out below (the undiscriminated object union, whose variants stay in the docblock, and genuinely
untyped schemas, where mixed is the faithful type).
For a per-spec, machine-readable view of which gaps affect YOUR spec, see the
unsupported constructs report. The report is emitted on every
openapi:generate run and lists every recorded gap with the exact JSON pointer into your spec.
Multi-file specs and unresolvable $refs
Section titled “Multi-file specs and unresolvable $refs”The generator reads one document. An external $ref
(./common.yaml#/components/schemas/Thing) or a pointer outside #/components/schemas is not
resolved: the referencing value degrades to mixed with presence-only validation, an allOf
member pointing outside the document is not merged into the composed class, and on the server
scaffold the affected operation falls back to Request/JsonResponse. The same degradation
applies to a $ref naming a component that does not exist, and to a component entry that is
itself a bare $ref (schemas.X: { $ref: ... }), which is skipped.
Each of these emits a build warning naming the offending pointer and the schema or operation where
it was encountered, so a multi-file spec can no longer produce hollow output invisibly. If your
spec is split across files, bundle it into a single document first (for example with
redocly bundle or swagger-cli bundle) and generate from the bundled output.
allOf is merged
Section titled “allOf is merged”A schema composed with allOf is flattened: every member schema is merged into a single class.
Members may be inline objects or $refs to other components, and members that themselves use
allOf are merged recursively. Each member’s properties are unioned and the required lists are
concatenated and deduplicated.
Extended_Price: allOf: - type: object properties: billingCurrency: { type: string } - $ref: '#/components/schemas/Price'This generates an ExtendedPriceData class that carries billingCurrency plus every property
inherited from Price, with rules() covering all of them. When the same property name is defined
by more than one source, the value is resolved deterministically: a later allOf member overrides
an earlier one, and an explicit own-level property overrides every member. The first-seen position
is kept for ordering, so the class lists own properties, then the first member’s, then the next, and
so on.
A $ref member that is also a component in its own right keeps its standalone class. So Price
above is still emitted as PriceData and is also merged into ExtendedPriceData.
additionalProperties is represented as a typed map
Section titled “additionalProperties is represented as a typed map”Map-style schemas, an object whose keys are dynamic and whose values share a type, are represented
as a typed PHP array. laravel-data v4 has no first-class Map type, so an array<string, X> is the
correct representation. There are three forms.
Typed scalar value. A map whose values are a scalar schema becomes an array property with a
@var array<string, int> docblock and a wildcard value rule derived from the value schema. This is
the fully-enforceable case: the value constraints are validated per entry.
Language: type: object additionalProperties: type: integerA property whose schema is $ref: Language generates:
/** @var array<string, int> */public readonly ?array $language = null,with rules 'language' => ['sometimes', 'array'] and 'language.*' => ['integer']. A value schema
of { type: string, maxLength: 10 } yields 'language.*' => ['string', 'max:10'], so the value
constraints carry through verbatim.
Untyped map (additionalProperties: true). Becomes @var array<string, mixed> with a single
['array'] rule and no per-value rule.
$ref value. A map of objects (for example additionalProperties: { $ref: Price }) becomes
@var array<string, PriceData> with 'field' => ['array'] and 'field.*' => ['array'].
Key-count bounds (minProperties / maxProperties)
Section titled “Key-count bounds (minProperties / maxProperties)”minProperties and maxProperties are emitted as min: / max: rules on the property: a JSON
object arrives as a PHP array, and Laravel’s min: / max: count the elements of an array value,
so the key count maps directly (exactly like minItems / maxItems on an array). The bounds are
emitted wherever the schema is known to describe an object: a typed or untyped map (inline or
via $ref to a pure-map component), an explicit inline type: object property, a $ref to a
component with an explicit type: object, and an object-shaped map value (on the field.*
wildcard).
Two cases are deliberately skipped because emitting would risk false-rejecting valid data or cannot be expressed per-field:
- Untyped schemas. A schema without
type: object(and not map-shaped) may legally hold a non-object instance, which JSON Schema exempts fromminProperties/maxPropertiesentirely, but Laravel’smin:/max:would measure a string’s length or a number’s value. No rule is emitted there. - A component’s own root payload. Generated
rules()are keyed per field, so a component’s ownminProperties/maxPropertiesare not enforced when that component is the request body root. They are enforced everywhere the component appears as a property.
Pure-map components
Section titled “Pure-map components”A component that is a pure map (type: object with only additionalProperties and no named
properties) is not emitted as its own Data class. Emitting an empty class would be a bug.
Instead the array type is inlined wherever the component is referenced, so a property of type
$ref: Language is generated directly as array<string, int>. The map is represented at every use
site, deterministically, with no empty wrapper class.
Non-object alias components (scalar, array, oneOf/anyOf)
Section titled “Non-object alias components (scalar, array, oneOf/anyOf)”A top-level component that is itself a scalar (Time: { type: string, format: date-time }), an array
(Tags: { type: array, items: { type: string } }), or a oneOf/anyOf union
(IntOrString: { oneOf: [{ type: integer }, { type: string }] }) has no object properties, so it
is a type alias, not a Data class. Such a component is not emitted as an empty Data class.
Instead, every $ref to it resolves to the underlying type at the use site: a scalar alias becomes the
scalar PHP type plus the constraint it carries (a date-time alias still emits the date-time rule, a
length-bounded string alias its max:/min:), an array alias becomes the array/DataCollection
type its items imply, and a union alias becomes the native PHP union (or mixed for the messy cases,
per the usual oneOf/anyOf rules). Chained aliases (A: { allOf: [{ $ref: B }] } where B is itself
an alias) resolve transitively, with a cycle guard. The same resolution applies on the server scaffold:
a request/response $ref to such a component types against the underlying value rather than a missing
class, falling back to Request/JsonResponse when the underlying type is not a Data class (the
request-body fallback warns; the response-side fallback is by design, see
Server scaffold gaps).
A type: object component with no properties (or properties: {}) is a genuinely empty object, not
an alias, and still emits an (empty) Data class. The distinction is deliberate: only non-object
(scalar/array/composition) components are aliased.
An empty Data class compiles fine but silently drops every payload field on hydration, which is
invisible until data goes missing at runtime. The generator therefore makes the gap explicit: the
empty class body carries a marker comment (// The spec defines no properties for this schema.) and
generation emits one build warning per empty class, naming the schema. The same applies to a
read/write variant whose every property was dropped by the split (for example a write variant of a
schema whose properties are all readOnly). A closed empty object (additionalProperties: false
under the default enforcement) is not flagged: it carries the closed-object rule, so unknown fields
are rejected rather than dropped.
Mixed objects (named properties + additionalProperties)
Section titled “Mixed objects (named properties + additionalProperties)”When a schema declares both named properties and additionalProperties, the named properties are
emitted normally with full rules. The dynamic overflow (keys beyond the named ones) is not
captured: laravel-data cannot route unknown keys into a designated property without a custom cast, so
rather than emit a non-functional overflow field that silently fails to hydrate, the generated class
carries a docblock note that the dynamic keys are not captured. Read the known fields from the Data
object, and reach into the original payload for the overflow if you need it.
additionalProperties: false (enforced by default)
Section titled “additionalProperties: false (enforced by default)”By default, additionalProperties: false is enforced: a schema that explicitly closed its
shape gets that shape rejected for any undeclared key. The spec is the source of truth, so the author’s
intent is honored without a flag. Every schema that declares additionalProperties: false emits a
single NoUnknownPropertiesRule (inlined into your own <output>/Support/) that rejects any input
key outside the declared property set. It runs once per object, even when the payload omits every
declared property, so a payload that is nothing but unknown keys is still rejected. Schemas that do
not declare additionalProperties: false are untouched.
If you need the lenient, forward-compatible behavior instead, opt out. Enforcing a closed shape carries a forward-compatibility hazard: a producer that adds a new field to its responses would break a consumer that has not regenerated its Data classes yet, since the unknown field would be rejected. During that kind of contract evolution, turn enforcement off:
- artisan:
php artisan openapi:generate --no-enforce-closed-objects - standalone binary:
vendor/bin/openapi-laravel generate --no-enforce-closed-objects - config: set
'enforce_closed_objects' => falseinconfig/openapi-laravel.php
With enforcement opted out, the closed-object rule is never emitted and unknown keys are accepted,
matching the pre-default-flip output. Passing both --enforce-closed-objects and
--no-enforce-closed-objects is a configuration error (exit 2).
patternProperties under a closed object
Section titled “patternProperties under a closed object”A schema may combine additionalProperties: false with patternProperties: keys matching one of the
patterns are then spec-legal even though they are undeclared. The closed-object rule honors this: the
patterns are embedded as a second allow-list, and an input key matching any of them is accepted. Two
caveats, both surfaced as build warnings:
- Key admission only. The pattern’s value schema is not validated (
patternPropertiesis not translated into rules, see supported OpenAPI versions). A matching key is let through; its value is whatever the payload carries. - Pattern dialect. Spec patterns are ECMA-262 while PHP matches PCRE; like the
patternkeyword, the pattern is embedded as PCRE without translation. If a pattern does not compile as PCRE, the generator cannot tell legal keys apart, so it skips the closed-object rule for that schema entirely and warns: false-rejecting valid data is worse than under-validating.
Empty map encoding (fixed)
Section titled “Empty map encoding (fixed)”An empty additionalProperties map serializes as a JSON object {}, not an array []. PHP
cannot distinguish an empty associative array from an empty list (json_encode([]) emits []), so
every map-typed property carries a MapObjectTransformer that forces object encoding for the empty
case. This was a real wire bug surfaced by the cross-language
end-to-end demo (a strict client expecting Record<string, string>
received [] and rejected it) and was fixed in 0.5.0. It stays documented here because it is the
kind of seam only a live cross-language round trip exposes.
oneOf / anyOf union residuals
Section titled “oneOf / anyOf union residuals”The full oneOf / anyOf and discriminated union feature is documented on the Unions & polymorphism page. The honest residuals that remain:
- Undiscriminated object unions are presence-only (issue #31): a
oneOf/anyOfof Data classes with nodiscriminatoris typedmixedand validates for presence only, so every valid variant is accepted and no valid payload is false-rejected. Add adiscriminatorto the spec to get full variant validation and hydration. - allOf-inheritance standalone validation gap: for the allOf-inheritance discriminator form,
when a variant does not pin its discriminator with a
const, validating the variant standalone (outside the morph base) does not reject a wrong discriminator value. Morph routing through the base is unaffected.
Responses are not validated against the spec at runtime
Section titled “Responses are not validated against the spec at runtime”The scaffold enforces the request half of the contract: a body or query payload is validated
against the spec-derived rules() before your code runs. The response half is typed, not
validated. The abstract method’s return type pins the PHP shape, but the serialized JSON is never
checked against the spec: constraint rules run at validation (input) time only, so
PetData::from($model) happily serializes a value that violates maxLength or an enum constraint;
a JsonResponse fallback carries no schema at all; and error responses (the 404 and 422 bodies the
spec declares) are never typed in the controller signature.
The 422 failure path has a dedicated mitigation: Laravel’s default validation-error body
({message, errors}) rarely matches the error schema a spec declares for 422, but the declared
error schemas do generate Data classes, and one exception renderer in bootstrap/app.php
reshapes the body through them so the failure path matches the contract, type-checked. The
validation error shape guide holds the recipe and the decision record
(a documented renderer, not a generated one, issue #79).
The recommended mitigation for everything else is contract testing: wire Spectator against the same spec file the generator reads and assert response conformance in your test suite, per operation and per declared error status. The contract testing guide walks through the setup against a generated scaffold, including the worked Pest example and CI integration.
Server scaffold gaps
Section titled “Server scaffold gaps”Where the scaffold cannot derive a typed Data class it falls back to injecting
Illuminate\Http\Request (request side) or returning JsonResponse (response side). The fallback
compiles and runs, but the spec-declared body validation or return typing is absent there, so every
fallback emits a build warning naming the operation.
For the full list of what falls back and why, including request body and response fallback rules, see Request & response bodies.
For security middleware residuals (OR alternatives, scopes), see Security & middleware.
String formats: a few are no-ops
Section titled “String formats: a few are no-ops”A format on a string is mapped to the closest Laravel rule where one exists. email/idn-email
become email, uuid becomes uuid, uri/url/iri become url, and ipv4/ipv6/ip map to
the matching IP rule. hostname enforces RFC 1123 syntax via a dedicated HostnameRule. Date and
time formats are mapped strictly, each with its own rule: date becomes date_format:Y-m-d (a bare
calendar date, a timestamp is rejected), date-time becomes a dedicated RFC3339 rule that accepts
the Z, numeric-offset, and fractional-second timestamp forms and rejects a bare date, time
becomes an RFC3339 full-time rule, and duration becomes an ISO 8601 duration rule.
The following string formats are intentionally not validated and emit only the base string
rule: byte, binary, and password. Laravel has no faithful built-in for them, and a hand-rolled
regex would be either too loose to add value or too strict and wrongly reject valid input, so they
are treated as plain strings. idn-hostname is deliberately lenient (a non-empty, no-whitespace
check): a strict ASCII regex would wrongly reject valid unicode labels, and full unicode/punycode
validation is out of scope. The value still arrives and round-trips, it is simply not (or only
loosely) format-checked. Add your own rule if you need to enforce one of these.
Numeric constraints
Section titled “Numeric constraints”Inclusive minimum/maximum emit min:/max:. The exclusive forms emit gt:/lt:, covering both
the OpenAPI 3.0 boolean companion (minimum: N with exclusiveMinimum: true) and the 3.1 numeric
keyword (exclusiveMinimum: N). multipleOf is enforced with a small reusable MultipleOfRule
(Laravel has no native rule for it), and array uniqueItems: true adds a distinct rule on the
items. Numeric enum values, including floats, emit a Rule::in([...]) constraint; a top-level
number-enum component (which cannot be a PHP backed enum) becomes a single-value Data class that
carries that constraint rather than an empty class.
format: int32 and format: int64 emit range rules. An integer schema with format: int32
gets min:-2147483648 and max:2147483647; format: int64 gets min:-9223372036854775808 and
max:9223372036854775807. The range rules are emitted as Laravel rule strings, not PHP integer
literals (the int64 minimum equals PHP_INT_MIN and a literal would overflow to float). An explicit
minimum or maximum on the schema wins per side: if you declare minimum: 0 on an int32 field,
the spec bound (min:0) is used and the format lower bound (min:-2147483648) is suppressed for
that side only. Note that PHP’s int type cannot represent values beyond PHP_INT_MIN /
PHP_INT_MAX without arbitrary-precision support, so an int64 value at the spec boundary validates
correctly but may not round-trip as a PHP integer on 32-bit or very large values.
Tuple prefixItems: per-index rules, loose typing
Section titled “Tuple prefixItems: per-index rules, loose typing”A positional tuple (OpenAPI 3.1 prefixItems) is validated per position but typed loosely.
Laravel addresses tuple positions directly (field.0, field.1, …), so each position schema
emits its own rule list through the same constraint mapping as any other value: scalar type rules,
string/number bounds, formats, enums (inline Rule::in and $ref’d backed enums via Rule::enum),
and nullable for a nullable position.
coordinate: type: array prefixItems: - type: number - type: number - type: string enum: [WGS84, ETRS89] items: falseThis generates
'coordinate' => ['sometimes', 'array', 'max:3'],'coordinate.0' => ['numeric'],'coordinate.1' => ['numeric'],'coordinate.2' => [Rule::in(['WGS84', 'ETRS89'])],The closed-tuple spelling items: false (“no items beyond the prefix”) survives the pre-parse
normalizer as a synthesized maxItems of the tuple size, so the length cap is enforced as the
max:3 above. An explicit tighter maxItems wins; minItems passes through unchanged, so a spec
that pins both bounds gets a fixed-length tuple.
What stays loose, honestly:
- Typing degrades to
array<int, mixed>. PHP has no native tuple type, and a per-position typed structure (a generated shape class) is not emitted. The property is a plain?arraywith anarray<int, mixed>docblock; validation carries the per-position contract instead. - A post-prefix
itemsschema is not enforced. A 3.1itemsnext toprefixItemsconstrains only the elements AFTER the prefix, but a Laravelfield.*rule would also hit the prefix positions and false-reject valid tuples, so those extra elements stay unvalidated (presence-only).uniqueItems: truestill applies to every element viadistinct. - A multi-type or composition position stays presence-only. A position typed
["string", "integer"]or built fromoneOf/anyOf/allOfgets no type rule, exactly like the same construct at a property site, because a single type rule would false-reject the other valid members.
const is a single-value enum
Section titled “const is a single-value enum”A schema with const (OpenAPI 3.1 / JSON Schema) pins a value to one literal. The generator treats
it as a one-value enum: the property keeps its concrete type and gains a single-value Rule::in
constraint.
channel: type: string const: stableThis generates public readonly ?string $channel with 'channel' => ['sometimes', Rule::in(['stable'])].
A bare const with no declared type infers its PHP type from the literal (const: stable is a
string, const: 3 is an int). Only scalar const values (string and integer) are enforced this
way; an array, object, boolean, or null const falls back to the normal type handling.
The not keyword: tractable forms supported, rest recorded
Section titled “The not keyword: tractable forms supported, rest recorded”The not keyword is partially supported. Two forms have a direct Laravel equivalent and are
enforced:
not: {enum: [...]}emitsRule::notIn([...]), so a field that must NOT be one of a fixed set of values is validated at runtime.not: {const: X}emitsRule::notIn([X]), the same mechanism for a single excluded value.
All other not shapes have no faithful Laravel rule equivalent and are not enforced:
- A type-exclusion
not(for examplenot: {type: string}): Laravel has no “must not be this type” rule. - A nested object
not: too complex to invert into individual field rules. - A composition
not(not: {allOf: [...]},not: {oneOf: [...]}, etc.): no direct inversion. - A
notwhose innerconstis a float, boolean, array, or object (not a scalar string/integer): the literal helper cannot express those.
For every intractable not, the property is generated without the constraint (no false-rejects, but
the exclusion is absent). Each such gap is recorded in the
unsupported constructs report under the construct name
not (intractable), so you can see exactly which fields in your spec are affected.
Per-property required is ignored (it is non-standard)
Section titled “Per-property required is ignored (it is non-standard)”Some spec generators emit a boolean required: true key INSIDE an individual property schema:
User: type: object properties: email: type: string required: true # non-standard, OpenAPI ignores thisOpenAPI 3.x does not honour a per-property required boolean. A property is required only when the
owning schema’s required: [...] array lists it. The generator follows the spec: the per-property
key is ignored and email above is generated as OPTIONAL (public readonly ?string $email = null,
with 'email' => ['sometimes', 'string']).
Because that information loss is silent, the run prints a diagnostic to stderr naming the property
and schema, for example: Property "email" on schema "User" has a non-standard per-property "required" key, which OpenAPI ignores. Use the schema-level "required" array instead. This field is generated as optional. To actually make the field required, move it into the schema-level array:
User: type: object required: [email] # this is what OpenAPI honours properties: email: type: stringQuery parameters
Section titled “Query parameters”Operations with in: query parameters generate a per-operation query Data class with spec-derived
rules() and a query-only fromQuery() factory (see
Parameters for how the class is injected or
called). The residuals:
Header parameters are now generated; only cookie parameters are not. in: header parameters
generate a per-operation <Operation>HeaderData class with spec-derived rules() and a
::fromHeaders($request) factory (issue #121). in: cookie parameters produce no typing and no
validation yet; they are not silently dropped (a warning names the operation and the parameters), so
read them from the Request yourself in the meantime.
Repeated-key array query parameters are a known limitation. The form + explode: true form
(?tags=a&tags=b, the OpenAPI default array serialization) collapses to a single value under PHP’s
query parsing, so only the last value survives. Non-exploded delimited arrays are handled, see below.
Non-flat parameters that ARE handled. A non-exploded delimited array is split in the generated
fromQuery() on its declared delimiter before the array rules run (issue #132): style: form, explode: false on comma, style: spaceDelimited on space, style: pipeDelimited on pipe. A
style: deepObject object parameter (Stripe’s ?filter[gte]=10&filter[lte]=20) is synthesized
as a nested object property with dotted nested rules (issue #131), since PHP parses the bracketed
keys natively into a nested array.
Parameters still skipped, with a warning. A parameter that cannot round-trip through Laravel’s parsing is skipped rather than given rules that would false-reject valid requests:
- a non-object
deepObjectschema, ordeepObject+explode: false, - object-shaped parameters that are not
deepObject, object maps, andcontent-typed parameters.
Each skip emits a warning naming the operation, the parameter, and the reason. An operation whose every query parameter is skipped gets no query class.
Array elements hydrate as the raw query strings. PHP’s query parsing produces strings, so an
array parameter’s elements are validated against the spec type (?ids[]=x is rejected for an
integer item schema) but hydrate as the raw strings ($query->ids is ['1', '2'], not [1, 2]).
Top-level scalar properties are unaffected: they hydrate to the declared int/float/bool types.
Boolean literals are normalized in fromQuery() only. The form-style literals ?flag=true /
?flag=false are mapped to 1/0 by the generated fromQuery() factory before validation. Calling
rules() through a different path (e.g. a hand-rolled validator over $request->all()) skips that
mapping and Laravel’s boolean rule will reject the literals. The mapping covers exactly the
lowercase true / false that OpenAPI form serialization produces; ?flag=1 / ?flag=0 pass
Laravel’s boolean rule natively without mapping, and uppercase variants (True, TRUE, FALSE,
…) are not mapped and are rejected.
Response status codes: only the selected success response
Section titled “Response status codes: only the selected success response”The scaffold types and status-enforces each operation against one success response: the
numerically smallest 2xx, the same selection that drives the abstract method’s return type. When
that status is not 200 the generated route attaches the inlined RespondsWithStatus middleware,
so a 201 create operation answers 201 and a 204 operation (typed void) answers 204 with an empty
body, out of the box (see
Request & response bodies). The residuals:
Other declared 2xx responses are not modeled. An operation declaring both a 200 and a 202 is typed and enforced against the 200, and an operation whose smallest 2xx is 200 carries no status middleware at all, so producing an alternative status is your code’s responsibility: return a response object with the status set explicitly. The middleware is only attached when the selected success status is non-200, and it normalizes the framework-default success status (any 2xx) to the declared one; an error response (4xx/5xx) or a redirect (3xx) you set yourself always passes through untouched.
A spec-violating 204 body is dropped. RFC 9110 forbids content on a 204, so a 204 operation’s
abstract method returns void even if the spec (incorrectly) declares a response schema on it,
and the middleware clears any body on the way out.
Status enforcement lives in the generated routes file. If you generate with --no-routes and
hand-write your own routes, attach the inlined middleware yourself
(RespondsWithStatus::class.':201') or set the status in your controllers.
this is the one renamed property
Section titled “this is the one renamed property”PHP allows every reserved word (for, class, list, match, …) as a parameter variable name,
so a property named after a keyword keeps that name. The single exception is this: $this is
illegal as a parameter. A property literally named this is emitted as $_this with a
#[MapName('this')] attribute, so the original wire key still round-trips on (de)serialization.
What is not affected
Section titled “What is not affected”Everything else generates fully: object schemas with properties, $refs between schemas,
allOf composition (merged into a flat class), additionalProperties maps (typed array with
per-value rules), nested objects and arrays (DataCollection), native enums, the read/write variant
split, scalar constraints (minLength, maxLength, pattern, format, minimum/maximum,
enum, int32/int64 range bounds), not: {enum} and not: {const} (emitting Rule::notIn),
the server scaffold, typed and validated query parameters (per-operation query Data classes), scalar
oneOf/anyOf union type hints, and discriminated object unions (abstract morphable base plus
variants, validated and hydrated). The spec-derived validation is presence-exact, with the caveats
noted above: undiscriminated object unions are presence-only (any object is accepted, no variant
enforced) by design, while all three discriminated forms (named-component, inline-union,
allOf-inheritance) are validated and hydrated, and the map caveats apply (raw-array $ref map
values, uncaptured mixed-object overflow, and additionalProperties: false enforced by default,
with --no-enforce-closed-objects as the lenient opt-out).