Skip to content

End-to-end demo

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:

  • Proof that two implementations, in two languages, both generated from one spec, agree on the wire against a spec that deliberately stresses the cross-language serialization seams.
  • A template a team can copy to bootstrap a spec-first project.

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 backend

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

Terminal window
cd e2e/e2e-tests
npm install
./run.sh

If you would rather bring the stack up by hand and poke at it (curl, a browser, anything), use docker-compose directly:

Terminal window
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:

Terminal window
cd e2e/e2e-tests
./run.sh --no-docker

Tear everything down when you are finished:

Terminal window
docker compose -f e2e/docker-compose.yml down

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

  • the seeded pets load (the generated client GETs, the SPA renders);
  • a valid create returns 201 and the generated backend stores it;
  • an invalid create (an empty name, a bad enum) returns 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;
  • a string external_id and a numeric external_id each round-trip with their JSON type intact, proving the oneOf scalar union;
  • an explicit null for weight_kg round-trips as null;
  • the attributes map round-trips through the full stack;
  • a delete removes the pet from the list (the generated client DELETEs, then the list re-fetches), and the underlying call returns 204;
  • the status filter tabs drive 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.

FieldSpec traitWhat it proves
microchip_idsnake_case wire name#[MapName] maps to the microchipId property and back, in both directions
secret_notewriteOnly: trueaccepted on create, never returned in any read response
created_atformat: date-time, readOnlydropped from the write variant, set server-side, ignored if a client sends it
weight_kgnumber, nullable: truea null is accepted and stays present as null in responses
attributesobject + additionalPropertiesa string-to-string map round-trips intact
external_idoneOf: [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 {}:

Terminal window
# 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.