API Contracts
The API contract system ensures the backend and frontend agree on every response shape. The backend defines contracts through Laravel Resources (snake_case JSON). The frontend enforces them through Zod schemas (camelCase TypeScript). A case transformation layer bridges the two.
The boilerplate includes a detailed contract specification file (CONTRACTS.md) in the repository that you can reference after purchase. This page covers the essential conventions you need to know.
Contract Principles
- Resources are the contract — Laravel Resources define the exact JSON shape. If a field is not in the Resource, it does not exist.
- Schemas validate the contract — Zod schemas on the frontend parse every response. If the backend breaks the shape, the parse fails immediately.
- snake_case on the wire — All API JSON uses snake_case. The frontend API client transforms to camelCase automatically.
- No surprises — Nullable fields always appear (as
null), never go missing. Computed fields (canUpgrade,isCancelable) are part of the contract and cannot disappear without versioning.
Response Conventions
Casing
All JSON keys use snake_case:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"pricing_type": "seat",
"price_cents": 999,
"created_at": "2026-01-15T10:30:00Z"
}
The frontend API client transforms this to camelCase before Zod validation:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"pricingType": "seat",
"priceCents": 999,
"createdAt": "2026-01-15T10:30:00Z"
}
Dates
ISO-8601 UTC format. Backend Resources use ->toIso8601String():
"created_at": "2026-01-15T10:30:00+00:00"
"trial_ends_at": null
Money
Money is always represented as an integer (minor units) plus a currency code:
{
"subtotal": {
"amount_cents": 4999,
"currency": "EUR"
}
}
After case transformation, the frontend sees:
{
subtotal: {
amountCents: 4999,
currency: "EUR"
}
}
The moneySchema validates this shape:
const moneySchema = z.object({
amountCents: z.number().int(),
currency: z.string().length(3),
})
amount_cents (integer) plus currency (string). No exceptions.Enums
Lowercase snake_case strings matching the backend enum value:
"status": "past_due"
"pricing_type": "seat"
"interval_unit": "month"
IDs
UUID strings for most entities. Users use integer IDs.
Booleans
Strict JSON booleans (true/false), never 0/1 or "true"/"false".
Nullable vs Optional
- Nullable fields always appear in the response, with value
nullwhen empty - Optional relations (loaded via
?include=plan) may be absent from the response
In Zod:
// Always present, can be null
cancellationReason: z.string().nullable()
// Only present when included
features: z.array(planFeatureSchema).optional()
Pagination
List endpoints return paginated responses with this structure:
{
"data": [ ... ],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 25,
"total": 123
}
}
The paginatedSchema factory creates the matching Zod schema:
import { paginatedSchema } from '@common/api/schema'
export const paginatedInvoicesSchema = paginatedSchema(invoiceSchema)
// Validates: { data: Invoice[], meta: PaginationMeta }
Error Responses
Validation Errors (422)
{
"message": "The given data was invalid.",
"errors": {
"plan_id": ["The plan id field is required."],
"currency": ["The selected currency is invalid."]
}
}
Domain Errors (422)
{
"message": "Tenant is already subscribed to this plan",
"code": "already_subscribed"
}
Authentication (401)
{
"message": "Unauthenticated."
}
The API client converts these into typed error classes:
import { ValidationError, isValidationError } from '@common/api'
try {
await api.createCheckout(input)
} catch (err) {
if (isValidationError(err)) {
// err.errors = { planId: ['...'], currency: ['...'] }
}
}
Versioning
All endpoints use URL-based versioning:
/api/v1/tenant/{tenantId}/subscription
/api/v1/tenant/{tenantId}/invoices
Backend controllers are namespaced under App\Http\Controllers\Api\V1\. Breaking changes require a new version (/api/v2/).
The Full Endpoint Flow
When you add a new API endpoint, the data flows through a well-defined pipeline. Here is the complete flow using subscription as an example.
Backend: Resource Defines the Shape
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status->value,
'plan' => new PlanResource($this->whenLoaded('plan')),
'currency' => $this->currency,
'price_cents' => $this->price_cents,
'quantity' => $this->quantity,
'interval_unit' => $this->interval_unit->value,
'interval_count' => $this->interval_count,
'current_period_start' => $this->current_period_start?->toIso8601String(),
'current_period_end' => $this->current_period_end?->toIso8601String(),
'cancel_at_period_end' => $this->cancel_at_period_end,
'created_at' => $this->created_at?->toIso8601String(),
];
}
Frontend: Schema Validates the Shape
export const subscriptionSchema = z.object({
id: z.string().uuid(),
status: subscriptionStatusSchema,
plan: billingPlanSchema.nullable().optional(),
currency: z.string().length(3),
priceCents: z.number().int().min(0),
quantity: z.number().int().positive(),
intervalUnit: intervalUnitSchema,
intervalCount: z.number().int().positive(),
currentPeriodStart: z.string().datetime().nullable(),
currentPeriodEnd: z.string().datetime().nullable(),
cancelAtPeriodEnd: z.boolean(),
createdAt: z.string().datetime(),
})
Frontend: Type is Inferred
export type Subscription = z.infer<typeof subscriptionSchema>
Frontend: API Module Fetches and Validates
async function getSubscription(options?: GetSubscriptionOptions): Promise<Subscription | null> {
try {
const response = await get<{ data: unknown }>(
tenantPath('/subscription'),
{ params: { include: options?.include?.join(',') } }
)
return subscriptionSchema.parse(response.data)
} catch (err) {
if (err instanceof ApiError && err.statusCode === 404) return null
throw err
}
}
Frontend: Composable Provides Reactive State
export function useSubscription() {
const api = useBillingApi()
const { data, status, error, refresh } = useAuthenticatedAsyncData(
'billing:subscription',
() => api.getSubscription({ include: ['plan', 'plan.features'] })
)
const subscription = computed(() => data.value ?? null)
const isActive = computed(() =>
isStatusInList(subscription.value?.status, ACTIVE_STATUSES)
)
// ... more computed helpers
return { subscription, isActive, /* ... */ }
}
The Mapping
| Backend (Resource) | Wire (JSON) | Frontend (Schema/Type) |
|---|---|---|
'price_cents' | "price_cents": 999 | priceCents: z.number() → priceCents: number |
->toIso8601String() | "created_at": "2026..." | createdAt: z.string() → createdAt: string |
$this->status->value | "status": "active" | status: z.enum([...]) → status: SubscriptionStatus |
new PlanResource(...) | "plan": { ... } | plan: billingPlanSchema → plan: BillingPlan |
Adding a New Endpoint
Follow these steps to add a new API endpoint end-to-end:
- Backend: Create the Resource (snake_case keys, ISO-8601 dates,
amount_centsfor money) - Backend: Create the Controller, Request, Action/Query
- Backend: Document the endpoint in the project's endpoint catalog
- Frontend: Add the Zod schema in
schemas.ts(camelCase keys) - Frontend: Add the type in
types.tsusingz.infer<> - Frontend: Add the API method in
api/<feature>.api.tswith.parse()validation - Frontend: Add the composable in
composables/ - Frontend: Export everything from
index.ts
What's Next
- Decision Records — How architectural decisions are documented
- Domain Layer — Backend Resources and the domain model
- Validation — Frontend Zod schema details