Skip to content

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.

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.

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: integer

A 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 from minProperties / maxProperties entirely, but Laravel’s min: / 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 own minProperties / maxProperties are not enforced when that component is the request body root. They are enforced everywhere the component appears as a property.

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' => false in config/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).

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 (patternProperties is 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 pattern keyword, 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.

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.

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/anyOf of Data classes with no discriminator is typed mixed and validates for presence only, so every valid variant is accepted and no valid payload is false-rejected. Add a discriminator to 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.

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.

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.

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: false

This 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 ?array with an array<int, mixed> docblock; validation carries the per-position contract instead.
  • A post-prefix items schema is not enforced. A 3.1 items next to prefixItems constrains only the elements AFTER the prefix, but a Laravel field.* rule would also hit the prefix positions and false-reject valid tuples, so those extra elements stay unvalidated (presence-only). uniqueItems: true still applies to every element via distinct.
  • A multi-type or composition position stays presence-only. A position typed ["string", "integer"] or built from oneOf/anyOf/allOf gets no type rule, exactly like the same construct at a property site, because a single type rule would false-reject the other valid members.

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: stable

This 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: [...]} emits Rule::notIn([...]), so a field that must NOT be one of a fixed set of values is validated at runtime.
  • not: {const: X} emits Rule::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 example not: {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 not whose inner const is 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 this

OpenAPI 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: string

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 deepObject schema, or deepObject + explode: false,
  • object-shaped parameters that are not deepObject, object maps, and content-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.

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.

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).