Skip to content
SaaS4Builders
Architecture

API Contracts

The contract system bridging Laravel Resources (backend) and Zod schemas (frontend): casing, money, dates, pagination, errors, and the full endpoint flow.

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

  1. Resources are the contract — Laravel Resources define the exact JSON shape. If a field is not in the Resource, it does not exist.
  2. Schemas validate the contract — Zod schemas on the frontend parse every response. If the backend breaks the shape, the parse fails immediately.
  3. snake_case on the wire — All API JSON uses snake_case. The frontend API client transforms to camelCase automatically.
  4. 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),
})
Never use floating-point for money. Always 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 null when 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

backend/app/Http/Resources/Api/V1/Tenant/SubscriptionResource.php
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

frontend/features/core/docs/billing/schemas.ts
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

frontend/features/core/docs/billing/types.ts
export type Subscription = z.infer<typeof subscriptionSchema>

Frontend: API Module Fetches and Validates

frontend/features/core/docs/billing/api/docs/billing.api.ts
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

frontend/features/core/docs/billing/composables/useSubscription.ts
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": 999priceCents: 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: billingPlanSchemaplan: BillingPlan

Adding a New Endpoint

Follow these steps to add a new API endpoint end-to-end:

  1. Backend: Create the Resource (snake_case keys, ISO-8601 dates, amount_cents for money)
  2. Backend: Create the Controller, Request, Action/Query
  3. Backend: Document the endpoint in the project's endpoint catalog
  4. Frontend: Add the Zod schema in schemas.ts (camelCase keys)
  5. Frontend: Add the type in types.ts using z.infer<>
  6. Frontend: Add the API method in api/<feature>.api.ts with .parse() validation
  7. Frontend: Add the composable in composables/
  8. Frontend: Export everything from index.ts
The boilerplate includes an endpoint catalog that lists every API route with its method, path, auth requirements, and response shape. Do not implement an endpoint that is not documented there — propose the shape first and wait for validation.

What's Next