One OpenAPI spec in, a working typed Laravel slice out: laravel-data models, spec-derived validation, native enums, controllers, and routes, out of the box, drift-gated in CI. The spec is the source of truth, your code follows it.
Documentation: openapi-laravel.codewithagents.de
Hand-written DTOs, validation rules, and controllers drift from the API contract silently. Nobody
notices the missing nullable, the renamed wire field, or the new enum case until production rejects
a valid payload, or a consumer files the bug for you. The spec already states every one of those
shapes; the drift exists only because humans re-type them.
So make the spec the source of truth and regeneration the sync mechanism. One command emits
spatie/laravel-data classes with explicit, spec-derived
rules() methods plus native PHP enums, an abstract controller per tag, and a routes file, so the
request/response types and the routing table derive from the spec too. When the spec changes, you
re-run the generator and review the diff.
Unlike annotation-driven tools that generate a spec from your code, this goes the other way: the spec drives the code. And unlike most generators, the output is not a black box. It is readable, deterministic PHP that lives in your repo and looks like code you would have written yourself.
// 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)],
];
}
}Sibling project of openapi-zod-ts, which does the same for TypeScript. Both are tested against a shared corpus of real-world public API documents, 135 specs on this side (detailed in Why quality matters below).
The generated classes extend Spatie\LaravelData\Data, so
spatie/laravel-data v4 is a runtime peer dependency of
your app (a normal require, not require --dev). The generator itself is a dev dependency.
The generator is a dev dependency; spatie/laravel-data is a runtime dependency of your app because
the generated classes extend it:
composer require spatie/laravel-data:^4.23
composer require --dev codewithagents/openapi-laravelPublish the config (optional):
php artisan vendor:publish --tag=openapi-laravel-configPoint it at your spec and generate:
php artisan openapi:generate --spec=openapi.yaml --output=app/DataOne command emits the full output: Data classes with rules(), native enums, one abstract
controller per tag (app/Http/Controllers/Api by default), and a routes/api.generated.php
file, all typed against each other. Then scaffold the concrete controllers the routes file
references, one time, so the app boots immediately:
php artisan openapi:scaffoldEach stub extends its generated abstract controller and implements every operation as an explicit
throw new LogicException('Not implemented: ...') placeholder. Existing files are skipped, never
overwritten: the stubs are your code from the moment they are written, and openapi:check never
inspects them. Models only? Opt out per run:
php artisan openapi:generate --no-controllers --no-routesThe artisan command writes into the namespace from config/openapi-laravel.php (output.namespace,
default App\Data), overridable per run with --namespace. Set spec and output.path in the
config too and you can then just run php artisan openapi:generate. Settings resolve with strict
precedence: flags beat the config, the config beats the built-in defaults (controllers.enabled and
routes.enabled can disable the scaffold permanently; --controllers / --routes force it back on
for one run).
Not a Laravel project? The same generator ships as a framework-free binary. It reads an optional
openapi-laravel.json from the working directory (or --config=<path>) whose keys mirror the
Laravel config, with the same flag-over-config precedence. Controllers and routes default to
<output>/Controllers and <output>/routes.php:
vendor/bin/openapi-laravel --spec=openapi.yaml --output=src/Data --namespace="Acme\\Dto"One spec drives models, validation rules, controllers and routes; the drift gate fails CI when they disagree, and regeneration never touches your code.
The same flow powers the cross-language e2e demo: one spec, a generated Laravel backend, and a TypeScript SPA.
Once the generated files are committed, add openapi:check to your CI pipeline. It regenerates
the full file set in memory and compares it byte-for-byte against what is on disk, without writing
anything:
php artisan openapi:check
# or, without Laravel:
vendor/bin/openapi-laravel checkExit codes: 0 the committed files match the spec, 1 drift detected (the build fails), 2 a
config or spec error. Add --diff to print a bounded unified diff per changed file so you can see
exactly what drifted. The check honors the same flags as openapi:generate (--spec, --output,
--namespace, --no-controllers, --no-routes) and
only compares generator-owned files, so hand-written concrete controllers are never flagged as
drift.
See the drift-check guide for a full CI walkthrough.
openapi.yaml
└── openapi-laravel → app/Data/CustomerData.php (laravel-data class + rules())
app/Data/CustomerStatus.php (native backed enum)
app/Data/CustomerWritableData.php (write variant, when the spec
uses readOnly/writeOnly)
app/Http/Controllers/Api/AbstractCustomerController.php
routes/api.generated.php
You write your business logic. The DTOs, their validation, the controller signatures, and the routing table stay in sync when the spec changes.
What the generator handles today:
- Objects →
laravel-dataclasses with promoted readonly constructor properties - Validation → explicit
rules()from the spec: required/nullable, types, string format/pattern, numeric bounds (inclusive and exclusive),multipleOf,uniqueItems,dependentRequired,Rule::enum/Rule::in - Enums → native PHP backed enums (string or int); float enums emit
Rule::in - Nested objects and collections → nested Data classes; typed
#[DataCollectionOf]arrays allOf→ merged into one flat class with dedupedrequiredoneOf/anyOf→ scalar unions emit native PHP union types; discriminated object unions emit an abstract morphable base plus per-variant classes (full validation and hydration); undiscriminated object unions are typedmixed, presence-only, no false-rejectsadditionalProperties→ typed maps (array<string, X>); enforced closed-object validation by default, opt out with--no-enforce-closed-objects- readOnly / writeOnly → separate read and write variants, only when the spec uses the flags (transitive: any descendant field triggers the split)
- Query, path, and header parameters → per-operation Data classes with spec-derived rules; delimited arrays split before validation;
deepObjectparams synthesized as nested objects; integer path params get a->whereNumber()route constraint - Request bodies → typed Data class for JSON, multipart, and form-urlencoded object bodies (inline or component
$ref);format: binaryparts typedUploadedFile - Response types → inline and component
$ref2xx object responses typed as the controller return (read variant); non-200 success status honored viaRespondsWithStatusmiddleware; 204 typedvoid - Server scaffold → abstract controller per tag plus a routes file; unimplemented operations are PHP fatals; Laravel-convention method names for RESTful operations
- Tag-grouped layout → Data classes and enums in per-tag subdirectories matching their namespace; multi-tag schemas stay at the flat root
- Drift gate →
openapi:checkregenerates in memory and compares byte-for-byte; exit 0/1/2
For known limitations and graceful-degradation cases see the limitations guide.
The same spec also produces a typed abstract controller per tag and a routes file. An operation you forget to implement is a PHP fatal at class-definition time, not a gap discovered in production:
// generated: app/Http/Controllers/Api/AbstractPetController.php
// (this Pet schema marks some fields readOnly/writeOnly, so the request type is
// the write variant PetWritableData and the response type is the read variant
// PetData; a schema with no such flags would use a single PetData both ways)
abstract class AbstractPetController
{
// clean RESTful operations get the conventional Laravel method names;
// anything ambiguous or non-CRUD keeps its operationId-derived name
abstract public function store(PetWritableData $pet): PetData;
abstract public function show(int $petId): PetData;
abstract public function destroy(int $petId): void; // the spec declares 204: nothing to return
}
// generated: routes/api.generated.php
// (the spec declares 201 for the create and 204 for the delete, so those
// routes carry the inlined status middleware; plain 200 operations stay untouched)
Route::post('/pet', [PetController::class, 'store'])->name('store')->middleware(RespondsWithStatus::class.':201');
Route::get('/pet/{petId}', [PetController::class, 'show'])->name('show');
Route::delete('/pet/{petId}', [PetController::class, 'destroy'])->name('destroy')->middleware(RespondsWithStatus::class.':204');You write only the concrete PetController extends AbstractPetController, and
php artisan openapi:scaffold (or vendor/bin/openapi-laravel scaffold) writes its initial,
one-time stub for you. See the
server scaffold guide for the
full walkthrough.
A few OpenAPI features degrade gracefully rather than crash. An undiscriminated object union is typed mixed with presence-only validation (no false-rejects). Non-object request and response bodies fall back to Illuminate\Http\Request / JsonResponse with a generator warning. A non-standard per-property required: true boolean is ignored in favour of the schema-level required array, with a diagnostic on stderr. See the limitations guide for the full, honest list.
The Customer example above is the easy 80%. The schema below is the kind that breaks generators: the
Pet schema from the e2e demo spec (e2e/spec/petstore.yaml) 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.
Trimmed to the interesting parts:
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: integerThe generator turns that into two classes, because the readOnly/writeOnly flags mean the read shape
and the write shape differ on the wire. The read variant
(e2e/backend/app/Data/PetData.php, trimmed):
final 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 write variant (e2e/backend/app/Data/PetWritableData.php)
is the mirror image: it carries secret_note (writeOnly, accepted on create, never read back) and
drops created_at (readOnly, set server-side). 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.
The spec is the source of truth. Code follows the contract, never the other way around. This is the opposite of annotation-driven tools where your PHP generates the spec.
You own the output. Generated classes are readable PHP in your repo. Review them, commit them, read them in a diff. No opaque runtime, no reflection magic you can't follow.
Explicit over inferred. Validation rules are emitted verbatim from spec constraints, not guessed from property types at runtime. What the contract says is what gets validated.
Deterministic. Stable ordering everywhere. Regenerating produces a byte-identical diff or no diff at all, so the generator is safe to run in CI and commit.
Modern only. PHP 8.2+, Laravel 11/12/13, laravel-data v4. No legacy shims.
The Laravel ecosystem is full of code-first tools that generate an OpenAPI document from your controllers and annotations: l5-swagger, dedoc/scramble, vyuldashev/laravel-openapi. Those are excellent if your code is the source of truth. This tool is for the other direction: spec-first, where the OpenAPI document is the contract and your models derive from it.
openapi-laravel |
l5-swagger / scramble | ensi-platform/* | hand-writing | |
|---|---|---|---|---|
| Direction | Spec → code | Code → spec | Spec → code | n/a |
| Generates laravel-data DTOs | Yes | No | No (custom DTOs) | You do |
Spec-derived validation rules() |
Yes, differentially tested against the spec | No | Partial | You do |
| Native PHP enums | Yes | No | No | You do |
| Server scaffold (abstract controllers + routes) | Yes (default) | No | Yes | You do |
allOf / additionalProperties |
Yes | n/a | Partial | You do |
oneOf / anyOf |
Scalar union type hints; discriminated object unions validated and hydrated; undiscriminated ones presence-only, no false-reject | n/a | Partial | You do |
| Minimum Laravel version | 11 | 9+ | 10+ | n/a |
| Runtime peer dependency | spatie/laravel-data v4 |
none | own DTO layer | none |
| Standard OpenAPI (no custom extensions) | Yes | Yes | No (custom OAS) | n/a |
| Owned, readable, committed output | Yes | n/a | Generated | Yes |
| Runs without Laravel (CI) | Yes (bin) | No | No | n/a |
| Drift detection in CI | Yes (openapi:check, byte-level, exit 1 on drift in generator-owned files) |
n/a (spec is generated from code) | No | Manual review |
The spatie/laravel-data v4 runtime peer is a real adoption cost: the generated DTOs are
laravel-data classes, so your app takes on that dependency and its conventions.
Pick something else if:
- your code is the source of truth and you want the spec generated from it (l5-swagger, scramble);
- you are on Laravel 10 or older, or your app standardizes on a DTO/validation layer other than
spatie/laravel-datav4; - you need a non-PHP target (the sibling openapi-zod-ts covers TypeScript).
An agent can write a Data class from your spec, and the first one will probably be fine. But the output is non-deterministic (the same prompt produces a different class tomorrow), nobody reviews the hundredth one, and the moment the spec changes you are back to hand-maintained code that drifts. This generator is deterministic: same spec in, byte-identical files out. That makes the output diffable, reviewable once instead of every time, and re-runnable in CI on every spec change.
The honest kicker: agents built this generator. The generator is what makes their output trustworthy on the hundredth run, not just the first.
The strongest claim a generator can make is that its output actually interoperates over the wire. The
e2e/ directory proves exactly that from a single spec, and the full cross-language loop is
green: one spec drives a generated Laravel backend and a generated TypeScript client and SPA, and a
Playwright headless-Chrome suite drives the browser through the whole stack over real HTTP.
e2e/spec/petstore.yaml (one OpenAPI document, the source of truth)
│
├── openapi-laravel → a real Laravel 12 backend
│ (Data classes + abstract controllers + routes)
│
└── openapi-zod-ts → a typed TypeScript client → a SPA
│
Playwright headless-Chrome E2E, over real HTTP:
browser → SPA → generated client → Laravel backend
One spec, two languages, no hand-written types on either side of the wire. Run the whole thing yourself (you need Docker Desktop and Node.js 18+); the runner brings the stack up, runs the headless-Chrome suite, and tears it down:
cd e2e/e2e-tests && npm install && ./run.shThe suite proves the cross-language serialization seams round-trip over real HTTP: a snake_case
wire field forcing #[MapName] (mapped both directions), a writeOnly field accepted on write and
never read back, a readOnly date-time set server-side and ignored on input, a nullable number
where null stays null, an additionalProperties map that round-trips intact, and a
oneOf: [string, integer] scalar union with no coercion. A valid create returns 201, an invalid
one returns 422 from the spec-derived rules() surfaced in the browser, and a delete returns 204.
Running two independent generators against one contract surfaced two honest findings, both since
addressed: an empty additionalProperties map serialized as [] rather than {} (the classic PHP
empty-array ambiguity), now fixed in 0.5.0 so empty maps serialize as {}; and the generated
openapi-zod-ts client omits the Accept: application/json header, which broke browser
content-negotiation against Laravel until a small middleware was added in the demo backend (filed
upstream as openapi-zod-ts #289).
Use it two ways: as proof that both generated sides agree on the wire, and as a template a
team can copy to bootstrap a spec-first project. See e2e/ for the full reference.
The generator reads an OpenAPI document and writes PHP that your application then loads and executes, so it treats the spec as untrusted input. Docblock injection is neutralized, namespace and class-name options are validated before any file is written, validation patterns are never silently dropped, non-OpenAPI documents are rejected with a clear error, and a pre-parse input-size guard caps the YAML alias-bomb blast radius. A hostile-input regression suite guards all of these. Output paths are written exactly where you point them by design, so point them at fixed, operator-controlled locations and never derive them from untrusted input. See the limitations guide for the full threat model and operator boundaries.
A code generator has a wide blast radius: a subtle regression touches every project that runs it.
- Differential validation oracle. A mutation-based test generates a class per spec constraint and runs valid and invalid payloads through the real Laravel Validator. A silently-dropped constraint fails the suite; a known-gap ratchet documents every acknowledged exception so gaps cannot accumulate silently.
- 135 real-world specs. The corpus covers Stripe, GitHub, OpenAI, Slack, Twilio, and 130 others. Every spec must parse, emit PHP that compiles (
php -l), and resolve every class reference on every CI run. - PHPStan max, 100% type coverage, mutation testing (Pest native, 90% minimum score), Deptrac architecture enforcement, and Laravel Pint style on every PR.
- Drift gate.
openapi:checkregenerates in memory and compares byte-for-byte; committed output that diverges from the spec breaks CI.
See the docs for the full quality story.
Current release is on Packagist (see the badge above); 0.13.0 is queued. The full feature set described in this README ships today.
Open before 1.0:
- Whole-body raw binary request bodies (issue #119):
application/octet-streamfalls back toIlluminate\Http\Request. - OpenAPI 3.2 (issue #102): full support is post-1.0; 3.2 specs generate today on a best-effort path with loud warnings.
The version stays 0.x until the output format settles, then tags 1.0.0 per the versioning policy. See ROADMAP.md for the release history and locked-in decisions.
MIT © codewithagents

