Unions & polymorphism
oneOf and anyOf emit union types
Section titled “oneOf and anyOf emit union types”A property whose schema uses oneOf or anyOf of scalars emits a native PHP union type hint
plus a @var docblock listing the variants. A union that contains an object member (a generated
Data class) is typed mixed with the variant union preserved in the @var docblock (see the object
union note below). oneOf and anyOf are handled identically for typing, since PHP cannot
distinguish “exactly one” from “at least one” shape.
animal: oneOf: - $ref: '#/components/schemas/Cat' - $ref: '#/components/schemas/Dog'This generates:
/** @var CatData|DogData */public readonly mixed $animal = null,A scalar union such as oneOf: [string, integer] keeps its native union type hint:
/** @var string|int */public readonly string|int|null $value = null,The scalar union is built in source order, deduplicated, with null forced to the end. null is
added to the union (and the property defaults to = null) when any member is the null type, any
member is itself nullable, or the property is nullable.
Fallback to mixed
Section titled “Fallback to mixed”When a member does not resolve to a clean type, the whole property falls back to the previous
behavior: public readonly mixed $payload with presence-only rules. This is deliberate and
deterministic. It happens when a member is itself a oneOf/anyOf, an array, a map, an inline
object, an untyped or empty schema, an enum, or a $ref to something that is not a Data class.
Each collapse emits a build warning naming the schema (and the pointer, when the messy member is a
$ref), so the degradation is visible at generation time instead of only in the generated type.
Rules stay presence-only
Section titled “Rules stay presence-only”The generated rules() keep presence semantics (required / sometimes / nullable). They do
not assert which variant was supplied: standard Laravel validation cannot enforce “exactly one
of these shapes” without a discriminator, and a type rule on a union would wrongly reject a valid
variant. When the spec does carry a discriminator, the generator wires it into runtime
dispatch instead, see Discriminated object unions
below.
This applies to scalar unions too. A oneOf: [string, integer] (or the JSON Schema multi-type
form type: ["string", "integer"]) emits the string|int type hint but only a presence rule, with
no per-variant type rule. The native PHP union already constrains the value to one of the member
types at hydration, but the validator does not separately assert “this is a string OR an integer”.
A field whose only constraint is being one of several scalar types is therefore validated for
presence only. If you need to reject, say, a boolean on a string|int field at the validation layer,
add a rule for it yourself.
Discriminated object unions are validated and hydrated
Section titled “Discriminated object unions are validated and hydrated”When a oneOf/anyOf carries a discriminator, the generator emits a real discriminated union: an
abstract morphable base plus one variant class per member. spatie/laravel-data reads the
discriminator value from the payload, selects the concrete variant, and validates and hydrates
that variant, so per-variant validation and polymorphic hydration both work at runtime, with no
custom cast. All three discriminator forms are supported (issue #38): the named-component form
(members are $refs, shown below), the inline-union form (members are inline object schemas), and
the allOf-inheritance form (a base object declares the discriminator and variants compose it via
allOf). The inline and allOf forms are described after the named-component example.
Given:
Pet: oneOf: - $ref: '#/components/schemas/Cat' - $ref: '#/components/schemas/Dog' discriminator: propertyName: petType mapping: { cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog' }Cat: type: object required: [petType, meow] properties: { petType: { type: string }, meow: { type: string } }Dog: type: object required: [petType, bark] properties: { petType: { type: string }, bark: { type: string } }the generator emits an abstract PetData whose only property is the discriminator (marked for morph)
plus a morph() that maps each discriminator value to a variant:
abstract class PetData extends Data implements PropertyMorphableData{ public function __construct( #[PropertyForMorph, Required, StringType] public readonly string $petType, ) {}
public static function morph(array $properties): ?string { return match ($properties['petType'] ?? null) { 'cat' => CatData::class, 'dog' => DogData::class, default => null, }; }}and one variant per member, each forwarding the discriminator and declaring its own properties:
final class CatData extends PetData{ public function __construct( string $petType, public readonly string $meow, ) { parent::__construct($petType); }
public static function rules(): array { return ['meow' => ['required', 'string']]; }}A property whose schema is $ref: Pet is typed as the abstract PetData, so laravel-data morphs it:
public readonly PetData $pet,At runtime, HolderData::from(['pet' => ['petType' => 'cat', 'meow' => 'mrr']]) hydrates ->pet as a
CatData; a dog payload hydrates a DogData. HolderData::validate(...) enforces the selected
variant’s rules: a cat discriminator missing meow is rejected, an unmapped discriminator value
is rejected (the default => null arm fails spatie’s morph guard), and a payload missing the
discriminator is rejected. An array of a discriminated union (items: { $ref: Pet }) works too:
each element morphs to its own variant and is validated per variant. The mapping is optional, when it
is omitted, the implicit discriminator value for each member is its component schema name.
Inline-union form. A oneOf/anyOf + discriminator whose members are inline object
schemas (not $refs) is handled too. Since the variants have no component name, the generator
synthesizes a deterministic, collision-safe Data class per member from its discriminator value:
Shape: oneOf: - { type: object, required: [kind, radius], properties: { kind: { const: circle }, radius: { type: number } } } - { type: object, required: [kind, side], properties: { kind: { const: square }, side: { type: number } } } discriminator: propertyName: kind mapping: { circle: '#/components/schemas/Shape/oneOf/0', square: '#/components/schemas/Shape/oneOf/1' }emits an abstract ShapeData base plus ShapeCircleData and ShapeSquareData variants, each
pinning its own discriminator via Rule::in, validated and hydrated exactly like the named form.
allOf-inheritance form. A base object that declares the discriminator directly, with variants
composed via allOf: [{ $ref: Base }, { ...own props }]:
Vehicle: type: object required: [vehicleType] properties: { vehicleType: { type: string }, wheels: { type: integer } } discriminator: { propertyName: vehicleType, mapping: { car: Car, truck: Truck } }Car: allOf: - $ref: '#/components/schemas/Vehicle' - { type: object, required: [doors], properties: { doors: { type: integer } } }The base VehicleData becomes the abstract morphable parent (declaring only the discriminator), and
CarData/TruckData extend it, forwarding the discriminator and declaring their own merged
properties.
A discriminated union that cannot be cleanly handled degrades to the presence-only behavior below and
emits a build warning explaining why. This happens when a member is not an object schema, when an
inline member has no derivable discriminator value (no mapping entry and no const/single-enum pin),
when a variant is shared by two discriminated bases (PHP single inheritance keeps the first base), or
when a base is left with no claimable variant. One honest residual remains for the allOf-inheritance
form: when its variants do not pin their discriminator with a const, validating a variant
standalone (outside the morph base) does not reject a wrong discriminator value (morph routing
through the base is unaffected). All tracked in
issue #38.
Unknown and missing discriminator values are clean 422s
Section titled “Unknown and missing discriminator values are clean 422s”An unknown discriminator value (a value not in the mapping, issue #124) is a clean 422, not an
uncatchable 500. The base’s morph() default arm throws a ValidationException instead of
returning null, so an unmapped value rejects before the CannotCreateAbstractClass error fires on
the creation paths.
A missing discriminator key (issue #126) is also a clean 422. The base declares the
discriminator property as nullable with a null default, so spatie’s morph resolver calls
morph() with null when the key is absent. The default arm rejects that too, and from(),
validateAndCreate(), and container injection all reject cleanly. null is the sentinel; no real
discriminator value is ever null.
Undiscriminated object unions are presence-only (no false-reject)
Section titled “Undiscriminated object unions are presence-only (no false-reject)”An undiscriminated object union (oneOf/anyOf of Data classes with no discriminator,
CatData|DogData) is typed mixed rather than the native union, and validated for presence only.
This is deliberate (issue #31). A native
CatData|DogData property type makes spatie/laravel-data infer nested validation rules from the
union and, lacking a discriminator, validate every payload against the first variant. With
oneOf: [Cat, Dog] where Cat requires meow, a valid Dog payload ({"bark": "woof"}) was then
false-rejected with “meow is required”. False-rejecting valid data is worse than under-validating,
so the property is typed mixed: every valid variant is now accepted, and the variant union stays in
the @var docblock for IDE and PHPStan.
The tradeoff is honest: an undiscriminated object union accepts any object (presence-only),
it does not enforce “the value is exactly one of these variants”. If your spec has a
discriminator, you get full variant validation and hydration automatically, see
Discriminated object unions above. Scalar
unions (string|int) are unaffected: they keep their native union type and hydrate directly.
Hydrating an undiscriminated object union today. Because the property is mixed, the value
arrives as a raw array at runtime. Add a discriminator to the spec to have the generator handle it
for you, or hydrate the chosen variant yourself by sniffing a distinguishing field. Given a spec with
a field you can switch on:
animal: oneOf: - $ref: '#/components/schemas/Cat' - $ref: '#/components/schemas/Dog' # no discriminator: dispatch on a field one variant carries, e.g. "kind"$pet->animal arrives as a raw array. Dispatch on the distinguishing field in your controller or
service:
$raw = $pet->animal; // raw array, e.g. ['kind' => 'cat', 'lives' => 9]
$animal = match ($raw['kind'] ?? null) { // a field you switch on yourself 'cat' => CatData::from($raw), 'dog' => DogData::from($raw), default => throw ValidationException::withMessages([ 'animal' => 'Unknown animal variant.', ]),};
// $animal is now a fully hydrated CatData or DogDataThe cleaner fix is to add a discriminator to the spec, which makes the generator emit the morphable
base and variants for you. Failing that, sniff a distinguishing field as above (for example a property
only CatData defines), or register a custom cast on the property. Scalar unions (string|int) need
none of this: they hydrate directly.
See also
Section titled “See also”- Generated output for the Data class format and the readOnly/writeOnly split.
- Limitations for the remaining union residuals.