One spec, committed
Keep a single OpenAPI document as the source of truth and generate everything from it.
The strongest claim a generator can make is that its output actually behaves correctly over the wire. The e2e/ directory in the repository proves exactly that from a single spec, and the full cross-language loop is now green: a Playwright headless-Chrome suite drives the SPA in a real browser, through the generated TypeScript client, over real HTTP, against the generated Laravel backend.
It is two things at once:
One OpenAPI document is the single source of truth. From it, two implementations in two languages are generated and proven to agree on the wire, with a browser-driven suite closing the loop:
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 backendThe Laravel side derives its Data classes, abstract controllers, and routes from the spec via openapi-laravel. The only hand-written PHP is the glue: the concrete controllers and a small file-backed store. The TypeScript side derives its client from the same spec via the sibling openapi-zod-ts, consumed by a SPA. A docker-compose stack boots both, and a Playwright headless-Chrome suite drives the browser through the whole stack over real HTTP.
You need Docker Desktop running and Node.js 18 or newer.
The fastest path is the one-command runner. From the repository root it brings the stack up, runs the headless-Chrome suite, and tears the stack down again:
cd e2e/e2e-testsnpm install./run.shIf you would rather bring the stack up by hand and poke at it (curl, a browser, anything), use docker-compose directly:
docker compose -f e2e/docker-compose.yml up -d --build# backend: http://localhost:8088 (API under /api, health at /up)# frontend: http://localhost:8080 (the built SPA)The SPA is at http://localhost:8080 and the API is at http://localhost:8088/api. With the stack already up, you can run just the Playwright suite against it:
cd e2e/e2e-tests./run.sh --no-dockerTear everything down when you are finished:
docker compose -f e2e/docker-compose.yml downAfter a run, open e2e/e2e-tests/playwright-report/index.html for the full HTML report.
The suite drives the SPA in headless Chromium and asserts the full round trip, browser → SPA → generated openapi-zod-ts client → real HTTP → generated Laravel backend. The verified behaviors are:
201 and the generated backend stores it;422 from the spec-derived rules(), surfaced in the browser;created_at (a readOnly field) is server-set and returned, while secret_note (a writeOnly field) is never returned;external_id and a numeric external_id each round-trip with their JSON type intact, proving the oneOf scalar union;null for weight_kg round-trips as null;attributes map round-trips through the full stack;DELETEs, then the list re-fetches), and the underlying call returns 204;findPetsByStatus over real HTTP and filter correctly.The shared spec is a “petstore-plus”: the stock petstore with extra fields added, each one targeting a known place where a typed client and a laravel-data server can quietly disagree. Every one is proven to round-trip end to end, both over curl against the backend and through the browser via the generated client.
| Field | Spec trait | What it proves |
|---|---|---|
microchip_id | snake_case wire name | #[MapName] maps to the microchipId property and back, in both directions |
secret_note | writeOnly: true | accepted on create, never returned in any read response |
created_at | format: date-time, readOnly | dropped from the write variant, set server-side, ignored if a client sends it |
weight_kg | number, nullable: true | a null is accepted and stays present as null in responses |
attributes | object + additionalProperties | a string-to-string map round-trips intact |
external_id | oneOf: [string, integer] | a scalar union: a string stays a string and an integer stays an integer, with no coercion |
Because the spec uses readOnly and writeOnly, the generator emits the read/write split: PetData (the response shape, with created_at and without secret_note) and PetWritableData (the request shape, with secret_note and without created_at). The generated AbstractPetController::addPet therefore takes a PetWritableData and returns a PetData, exactly the typing described in the server scaffold guide.
The external_id field exercises the oneOf / anyOf support. A scalar union is chosen deliberately: an undiscriminated object union does not auto-hydrate in laravel-data (a documented residual; a union with a discriminator is validated and hydrated, see the Unions & polymorphism page), whereas a scalar union round-trips cleanly and proves the union path end to end.
Running both generators together against one spec is not just a victory lap. It surfaced two real, honest findings that only a live cross-language round trip exposes, which is the whole point of running one.
1. An empty map serialized as [], not {} (an openapi-laravel finding, since fixed). An empty additionalProperties map round-tripped as a JSON array [], not an object {}:
# POST a pet with "attributes": {}# 201 -> "attributes":[]This is the classic PHP ambiguity: an empty associative array is indistinguishable from an empty list, so json_encode emits []. A non-empty map encoded correctly as an object, and a null stayed null, only the empty case was wrong, and only a strict typed client expecting Record<string, string> would notice. The fix shipped in 0.5.0: every map-typed property now carries a transformer that forces {} for the empty case. See the limitations page.
2. The generated client omits the Accept header (an openapi-zod-ts finding). The generated openapi-zod-ts client does not send an Accept: application/json header. When a browser fetch carries no explicit Accept, the browser default is text/html,..., so Laravel’s $request->wantsJson() returns false and its error path returns an HTML redirect instead of a 422 JSON response. That broke every state-changing request (POST, PUT, DELETE) in the browser until the demo backend added a small ForceJsonAccept middleware that sets Accept: application/json on every inbound API request.
This is not a bug in the generated Laravel code: the generated controllers, routes, and validation rules are correct. It is a gap in the openapi-zod-ts client generator, which should emit Accept: application/json alongside the Content-Type it already sends. It is filed upstream as openapi-zod-ts issue #289.
Both findings are the value of running two independent generators against one contract: each side checks the other, and the seams that only break across a real wire come out into the open.
The demo backend dogfoods the local package: it consumes codewithagents/openapi-laravel from the working tree through a Composer path repository, not from Packagist, so the demo always tests the current code. The published config points the generator at the shared spec and writes the Data classes, abstract controllers, and routes into the Laravel app. The generated files are committed on purpose, because they are the proof artifact. The Docker images boot the committed generated code as-is, without regenerating, so what ships in the repo is exactly what the suite proves.
If you are bootstrapping a spec-first project, the structure is the pattern to copy:
One spec, committed
Keep a single OpenAPI document as the source of truth and generate everything from it.
Generated backend types
Run openapi-laravel to derive Data classes, abstract controllers, and a
routes file. Write only the concrete controllers.
Generated client types
Run the sibling openapi-zod-ts over the same spec for a typed TypeScript client, so the frontend cannot drift from the contract either.
Round-trip tests
Drive the real stack over HTTP in CI so both sides are proven to agree on the wire, not just in theory.
See the e2e/ directory for the full, current reference.