Skip to content

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.

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.Customer
final 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)],
];
}
}

A few things to note:

  • required vs sometimes. Properties in the schema’s required array get required. Optional properties get sometimes, 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: email becomes the email rule. The generator maps uuid, date, date-time, uri / url, and ipv4 / ipv6 the same way.

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.CustomerStatus
enum CustomerStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Pending = '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).

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 fields
final 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)],
];
}
}

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

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.

config/openapi-laravel.php
'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.

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\Data

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

Schema-to-tag ownership is computed deterministically, with the same transitive $ref walk the subset closure uses:

WhatWhere it goes
Schema referenced (transitively) by operations of exactly one tagThat tag’s subdirectory, e.g. data/Pet/ under App\Data\Pet
Schema referenced by operations of several tagsFlat root (App\Data)
Schema referenced by no operationFlat root
EnumsSame rule as Data classes
Per-operation query and request-body classesTheir operation’s tag group (unambiguous: one controller tag per operation)
Nested inline classes and ...Writable variantsThe class that owns them
Inlined runtime support classesAlways 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 (matching UntaggedController).
  • Tag names become StudlyCaps directory segments (pet store and PetStore merge into Pet Store’s PetStore/), exactly like controller names.
  • The transitive walk follows properties, array items, additionalProperties values, allOf/oneOf/anyOf members, discriminator mappings, and the union base/variant linkage, so a tag that uses Pet also claims everything Pet needs; a schema two tags can reach goes to the root rather than to an arbitrary winner.
  • The group name Support is reserved for the inlined runtime classes: a tag normalizing to Support keeps 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.

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.

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.