One of the most common sources of bugs in full-stack apps is the frontend and backend diverging silently. You rename a field on the API, forget to update the TypeScript interface, and two weeks later you get a production bug report.
At work, we built a new PHP/Laminas backend alongside a Nuxt 3 frontend. From day one, I wanted to make the contract between them machine-enforced.
The setup
The backend uses OpenAPI attributes on every handler and DTO:
<?php
#[OA\Get(
path: '/customers',
operationId: 'getCustomers',
responses: [
new OA\Response(response: 200, description: 'List of customers', content: new OA\JsonContent(ref: CustomerListResponse::class))
]
)]
class GetCustomersHandler
{
// ...
}
A PHPStan rule enforces that every handler has OpenAPI documentation — missing it fails CI.
From those attributes, we generate an openapi.json spec file. That spec is the single source of truth.
Generating the TypeScript client
@hey-api/openapi-ts reads the OpenAPI spec and generates:
- Typed request/response interfaces
- A fully typed client (we use the
fetchclient) - SDK methods for every operation
npx @hey-api/openapi-ts \
--input ./openapi.json \
--output ./src/client \
--client @hey-api/client-fetch
The generated client looks like this on the consuming side:
import { getCustomers } from '@acme/api-client'
const { data, error } = await getCustomers({ query: { page: 1 } })
// data is fully typed — CustomerListResponse
Publishing to a private registry
We publish the generated client as a private npm package. The frontend installs it like any other dependency: npm install @acme/api-client.
Keeping it up to date: GitHub Actions
The real value comes from automation. A GitHub Actions workflow:
- Runs on every push that touches PHP files or
openapi.json - Regenerates the client
- Bumps the patch version
- Publishes the new package to the registry
The frontend's Renovate config picks up the new version automatically and opens a PR.
The result
The gap between API contract and frontend types is now zero — they're the same file. Schema drift is impossible without a failing CI job. And new developers don't need to figure out the API shape by reading PHP code; they just look at the TypeScript types.
This pattern works equally well with any typed backend that can emit an OpenAPI spec.