Generated output
This page shows the kind of PHP the generator emits. Everything here is plain, readable code that lives in your repo. There is no runtime black box behind it.
A Data class with rules()
Section titled “A Data class with rules()”Each object schema becomes a laravel-data class with promoted, readonly constructor properties and an explicit rules() method. The rules are derived verbatim from the spec constraints, not inferred from the property types.
<?php
declare(strict_types=1);
namespace App\Data;
use Illuminate\Validation\Rule;use Spatie\LaravelData\Attributes\MapName;use Spatie\LaravelData\Data;
// generated from components.schemas.Customerfinal class CustomerData extends Data{ public function __construct( public readonly int $id, public readonly string $name, #[MapName('email_address')] public readonly ?string $emailAddress = null, public readonly ?CustomerStatus $status = null, ) {}
public static function rules(): array { return [ 'id' => ['required', 'integer', 'min:1'], 'name' => ['required', 'string', 'max:255'], 'email_address' => ['sometimes', 'nullable', 'string', 'email'], 'status' => ['sometimes', Rule::enum(CustomerStatus::class)], ]; }}components: schemas: Customer: type: object required: [id, name] properties: id: type: integer minimum: 1 name: type: string maxLength: 255 email_address: type: string format: email nullable: true status: $ref: '#/components/schemas/CustomerStatus'A few things to note:
requiredvssometimes. Properties in the schema’srequiredarray getrequired. Optional properties getsometimes, so validation only fires when the key is present.#[MapName]. When the wire name (email_address) differs from the idiomatic PHP property (emailAddress), the generator adds a#[MapName]attribute so hydration and validation use the spec name.- Format mapping.
format: emailbecomes theemailrule. The generator mapsuuid,date,date-time,uri/url, andipv4/ipv6the same way.
A backed enum
Section titled “A backed enum”String and integer enums in the spec become native PHP backed enums. Enums are never given the Data suffix.
<?php
declare(strict_types=1);
namespace App\Data;
// generated from components.schemas.CustomerStatusenum CustomerStatus: string{ case Active = 'active'; case Inactive = 'inactive'; case Pending = 'pending';}components: schemas: CustomerStatus: type: string enum: [active, inactive, pending]The case names are StudlyCased from the values, and the backing values stay exactly as the spec wrote them. A class that references this enum validates against it with Rule::enum(CustomerStatus::class).
The readOnly / writeOnly split
Section titled “The readOnly / writeOnly split”When a schema marks fields with readOnly or writeOnly, the same object means two different shapes on the wire: what the server returns versus what the client sends. The generator emits a read variant and a write variant, but only when the spec actually uses the flags.
// generated from components.schemas.Customer (read shape)// includes readOnly fields, excludes writeOnly fieldsfinal class CustomerData extends Data{ public function __construct( public readonly int $id, // readOnly: server-assigned public readonly string $name, public readonly ?CustomerStatus $status = null, ) {}
public static function rules(): array { return [ 'id' => ['required', 'integer', 'min:1'], 'name' => ['required', 'string', 'max:255'], 'status' => ['sometimes', Rule::enum(CustomerStatus::class)], ]; }}// generated from components.schemas.Customer (write shape)// excludes readOnly fields, includes writeOnly fieldsfinal class CustomerWritableData extends Data{ public function __construct( public readonly string $name, public readonly string $password, // writeOnly: never returned public readonly ?CustomerStatus $status = null, ) {}
public static function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], 'password' => ['required', 'string', 'min:8'], 'status' => ['sometimes', Rule::enum(CustomerStatus::class)], ]; }}components: schemas: Customer: type: object required: [name] properties: id: type: integer minimum: 1 readOnly: true name: type: string maxLength: 255 password: type: string minLength: 8 writeOnly: true status: $ref: '#/components/schemas/CustomerStatus'The hard case
Section titled “The hard case”The Customer example above is the easy 80%. The schema below is the kind that breaks generators: the Pet schema from the end-to-end demo spec (e2e/spec/petstore.yaml in the repo) combines a nested $ref, a typed collection, an inline enum, a nullable number, an additionalProperties map, a scalar oneOf union, a snake_case wire name, and a readOnly/writeOnly split, in one object.
Pet: type: object required: [name, photoUrls] properties: category: $ref: '#/components/schemas/Category' # nested $ref tags: type: array items: $ref: '#/components/schemas/Tag' # typed collection status: type: string enum: [available, pending, sold] # inline enum microchip_id: type: string # snake_case wire name secret_note: type: string writeOnly: true # write shape only created_at: type: string format: date-time readOnly: true # read shape only weight_kg: type: number nullable: true # nullable number attributes: type: object additionalProperties: type: string # string-to-string map external_id: oneOf: # scalar union - type: string - type: integerfinal class PetData extends Data{ public function __construct( public readonly string $name, /** @var array<int, string> */ public readonly array $photoUrls, public readonly ?int $id = null, public readonly ?CategoryData $category = null, // nested $ref /** @var array<int, TagData> */ #[DataCollectionOf(TagData::class)] public readonly ?array $tags = null, // typed collection public readonly ?string $status = null, #[MapName('microchip_id')] public readonly ?string $microchipId = null, // wire name mapping #[MapName('created_at')] public readonly ?string $createdAt = null, // readOnly: read shape only #[MapName('weight_kg')] public readonly ?float $weightKg = null, // nullable number /** @var array<string, string> */ public readonly ?array $attributes = null, // additionalProperties map /** @var string|int */ #[MapName('external_id')] public readonly string|int|null $externalId = null, // scalar oneOf union ) {}
public static function rules(): array { return [ 'name' => ['required', 'string'], 'photoUrls' => ['required', 'array'], 'photoUrls.*' => ['string'], 'status' => ['sometimes', Rule::in(['available', 'pending', 'sold'])], 'attributes' => ['sometimes', 'array'], 'attributes.*' => ['string'], // ... trimmed ]; }}// the mirror image of PetData: carries secret_note, drops created_atfinal class PetWritableData extends Data{ public function __construct( public readonly string $name, /** @var array<int, string> */ public readonly array $photoUrls, public readonly ?int $id = null, // ... shared fields as in PetData, trimmed ... #[MapName('secret_note')] public readonly ?string $secretNote = null, // writeOnly: write shape only // ... more shared fields, trimmed; no createdAt here: readOnly fields // never appear in the write variant ) {}}The readOnly/writeOnly flags split the schema into two classes because the read shape and the write shape differ on the wire. The abstract controller types addPet(PetWritableData $pet): PetData, so the split is enforced at the signature level, not by convention.
This exact schema is exercised by the Playwright e2e suite over real HTTP: the #[MapName] field round-trips in both directions, the writeOnly secret never appears in a response, null stays null, the map round-trips intact, and the scalar union arrives uncoerced. See the end-to-end demo.
Customizing validation messages and attributes
Section titled “Customizing validation messages and attributes”Generated Data classes are overwritten on every regenerate, so never edit them to rephrase or
localize a validation error. The sanctioned extension point is a trait you own: set
output.validation_trait to its fully-qualified name and every generated Data class (models,
discriminated union bases and variants, per-operation query classes) pulls it in with a use
line.
'output' => [ // ... 'validation_trait' => 'App\\Support\\ApiValidationMessages',],The trait is the documented laravel-data hook surface: the package discovers static messages()
and attributes() methods on the Data class (see
working with the validator),
and methods supplied by a trait are found exactly like methods declared inline. Both methods
support dependency injection, so a translator or repository can be type-hinted as a parameter.
<?php
declare(strict_types=1);
namespace App\Support;
trait ApiValidationMessages{ /** * @return array<string, string> */ public static function messages(): array { return [ 'name.required' => 'Every customer needs a name.', 'email' => 'That does not look like a valid value.', ]; }
/** * @return array<string, string> */ public static function attributes(): array { return ['email' => 'e-mail address']; }}Each generated class then carries the trait:
use App\Support\ApiValidationMessages;use Spatie\LaravelData\Data;
final class CustomerData extends Data{ use ApiValidationMessages;
public function __construct( public readonly string $name, public readonly ?string $email = null, ) {}
// rules() ...}When CustomerData::validate(...) (or controller injection) runs, the real Laravel Validator
reports Every customer needs a name. for a missing name, and built-in messages for email
name the field e-mail address. A field.rule key targets one rule; a bare field key applies
to every rule on that field, at any nesting depth.
This survives regeneration by construction: the trait file is yours, it is never part of the
generated file set (not even under output.prune, which only clears the output directory before
rewriting the planned files), and the generated classes reproduce the same one-line use on every
run. The behavioral guarantee is pinned by a feature test that boots the generated classes, fails
validation through the real validator, asserts the customized message and attribute label
surface, regenerates, and asserts the output is byte-identical with the customization intact.
For the standalone binary the key lives in openapi-laravel.json as output.validation_trait (a
non-empty string). On both surfaces the value is validated as a legal fully-qualified PHP name
before anything is written; there is deliberately no CLI flag, because the trait is a standing
project convention, not a per-run switch.
Tag-grouped data layout
Section titled “Tag-grouped data layout”Data classes and enums are emitted in a per-tag layout that mirrors the controller grouping. A
flat directory does not scale (a real 176-path spec would pile 128 files into one data/
directory), so the generator owns the layout:
app/Data/├── Appointment/│ ├── AppointmentData.php // namespace App\Data\Appointment│ └── CreateAppointmentRequestData.php├── Patient/│ ├── PatientData.php // namespace App\Data\Patient│ └── ListPatientsQueryData.php├── Support/ // inlined runtime support, always here│ └── Rfc3339DateTimeRule.php└── AddressData.php // shared by both tags: flat root, App\DataNamespaces follow the directories, and every cross-group reference (a property type, a
#[DataCollectionOf(...)] target, a Rule::enum(...) class, a discriminated union’s extends or
morph() arm) is imported with a use statement, so the generated code always compiles.
The attribution rule
Section titled “The attribution rule”Schema-to-tag ownership is computed deterministically, with the same transitive $ref walk the
subset closure uses:
| What | Where it goes |
|---|---|
| Schema referenced (transitively) by operations of exactly one tag | That tag’s subdirectory, e.g. data/Pet/ under App\Data\Pet |
| Schema referenced by operations of several tags | Flat root (App\Data) |
| Schema referenced by no operation | Flat root |
| Enums | Same rule as Data classes |
| Per-operation query and request-body classes | Their operation’s tag group (unambiguous: one controller tag per operation) |
Nested inline classes and ...Writable variants | The class that owns them |
| Inlined runtime support classes | Always Support/ (App\Data\Support), unchanged |
The details, all deterministic:
- An operation belongs to its first tag, the same rule that names its controller, so the data
layout always mirrors the controller layout. Untagged operations count as the pseudo-tag
Untagged(matchingUntaggedController). - Tag names become StudlyCaps directory segments (
pet storeandPetStoremerge intoPet Store’sPetStore/), exactly like controller names. - The transitive walk follows properties, array items,
additionalPropertiesvalues,allOf/oneOf/anyOfmembers, discriminator mappings, and the union base/variant linkage, so a tag that usesPetalso claims everythingPetneeds; a schema two tags can reach goes to the root rather than to an arbitrary winner. - The group name
Supportis reserved for the inlined runtime classes: a tag normalizing toSupportkeeps its schemas at the flat root.
Multi-tag and unreferenced schemas go to the flat root, not a shared/ subdirectory: the root
already is the shared namespace, and keeping it avoids inventing a second special directory.
The output stays deterministic (same spec in, byte-identical files out), and openapi:check
verifies the grouped tree through the same shared planner as generate.
Determinism
Section titled “Determinism”The generator emits stable ordering everywhere: properties, rules, enum cases, and file output. The same spec in produces byte-identical files out, so re-running in CI yields either no diff or a diff that is entirely a consequence of the spec changing. The tag-grouped layout is equally deterministic.
Generated file set
Section titled “Generated file set”A full run (default flags) writes these file groups:
- Data classes and enums in the tag-grouped layout under
output.path. - Abstract controllers under
controllers.path(one file per OpenAPI tag). - Routes file at
routes.path. - Fidelity report (
openapi-laravel.unsupported.json): a single JSON file listing every OpenAPI construct in the spec the generator cannot faithfully represent. See Unsupported constructs report for the shape, the recorded constructs, and the opt-out.
All of these are deterministic and drift-checked by openapi:check. Use --no-controllers,
--no-routes, or --no-unsupported-report to exclude specific groups from both generation and
the check.