Validation
Every API response in SaaS4Builders is validated at runtime using Zod schemas. This catches contract violations the moment they happen — not when a user triggers a bug in production. Schemas are the single source of truth for frontend types: all TypeScript types are inferred from Zod with z.infer<>.
The Validation Pipeline
Backend Resource (snake_case JSON)
↓
API Client (toCamelCase transform)
↓
API Module (Zod .parse())
↓
Composable / Store (typed, validated data)
- The backend returns snake_case JSON via Laravel Resources
- The API client automatically transforms keys to camelCase
- The API module validates the transformed data with a Zod schema
- The composable receives typed, validated data
If the backend changes a field name or type, the Zod .parse() call throws immediately — you see the error in development, not in production.
Case Transformation
The backend API uses snake_case keys (Laravel convention). The frontend uses camelCase (TypeScript convention). The API client handles the conversion automatically:
/**
* Deep transform object keys from snake_case to camelCase.
*/
export function toCamelCase<T>(obj: unknown): T {
if (obj === null || obj === undefined) return obj as T
if (Array.isArray(obj)) {
return obj.map((item) => toCamelCase(item)) as T
}
if (isPlainObject(obj)) {
const result: Record<string, unknown> = {}
for (const key of Object.keys(obj)) {
const camelKey = snakeToCamel(key)
result[camelKey] = toCamelCase(obj[key])
}
return result as T
}
return obj as T
}
/**
* Deep transform object keys from camelCase to snake_case.
*/
export function toSnakeCase<T>(obj: unknown): T {
// Same recursive structure, calls camelToSnake on keys
}
The transformation is deep — it handles nested objects, arrays, and preserves Dates and primitives. It happens inside useApiClient():
- Outgoing requests:
toSnakeCase(body)before sending - Incoming responses:
toCamelCase(response)after receiving
priceCents maps to the API's price_cents.Schema Structure
Each feature has a schemas.ts file that defines all Zod schemas for that domain. Schemas are organized by type: enums, resources, inputs, and constants.
Real Example: Billing Schemas
import { z } from 'zod'
import { moneySchema } from '@common/types/money'
import { paginationMetaSchema, paginatedSchema } from '@common/api/schema'
// ─── Enums ───────────────────────────────────────────────────────────
export const subscriptionStatusSchema = z.enum([
'active', 'trialing', 'past_due', 'canceled',
'unpaid', 'paused', 'incomplete', 'incomplete_expired',
])
export const pricingTypeSchema = z.enum(['flat', 'seat', 'usage'])
export const intervalUnitSchema = z.enum(['day', 'week', 'month', 'year'])
// ─── Resource Schemas ────────────────────────────────────────────────
export const billingPlanSchema = z.object({
id: z.string().uuid(),
name: z.string(),
slug: z.string().optional(),
description: z.string().nullable().optional(),
pricingType: pricingTypeSchema,
intervalUnit: intervalUnitSchema,
intervalCount: z.number().int().positive(),
trialDays: z.number().int().min(0),
prices: z.array(z.object({
currency: z.string(),
priceCents: z.number().int().min(0),
})).optional(),
features: z.array(planFeatureSchema).optional(),
eligibility: planEligibilitySchema.optional(),
})
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(),
trialEndsAt: z.string().datetime().nullable(),
cancelAtPeriodEnd: z.boolean(),
canceledAt: z.string().datetime().nullable(),
cancellationReason: z.string().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
// ─── Input Schemas ───────────────────────────────────────────────────
export const checkoutInputSchema = z.object({
planId: z.string().uuid(),
currency: z.string().length(3),
successUrl: z.string().url(),
cancelUrl: z.string().url(),
quantity: z.number().int().min(1).optional(),
})
// ─── Status Constants ────────────────────────────────────────────────
export const ACTIVE_STATUSES = ['active', 'trialing'] as const
export const CANCELLABLE_STATUSES = ['active', 'trialing', 'past_due'] as const
export const PROBLEM_STATUSES = ['past_due', 'unpaid'] as const
export const SUBSCRIPTION_STATUS_COLORS: Record<string, string> = {
active: 'success', trialing: 'info', past_due: 'warning',
canceled: 'neutral', unpaid: 'error',
}
Key Schema Conventions
| Convention | Rule | Example |
|---|---|---|
| Field names | camelCase (after API transform) | priceCents, intervalUnit |
| Money fields | { amountCents: number, currency: string } | moneySchema |
| Dates | ISO-8601 string | z.string().datetime() or z.string() |
| IDs | UUID strings | z.string().uuid() |
| Enums | Lowercase snake_case values | z.enum(['flat', 'seat', 'usage']) |
| Nullable fields | .nullable() | description: z.string().nullable() |
| Optional relations | .optional() | features: z.array(...).optional() |
Type Derivation
Types are never defined manually for API data. They are always inferred from Zod schemas using z.infer<>:
import type { z } from 'zod'
import type {
subscriptionStatusSchema,
billingPlanSchema,
subscriptionSchema,
checkoutInputSchema,
// ... more schemas
} from './schemas'
// Enum types
export type SubscriptionStatus = z.infer<typeof subscriptionStatusSchema>
export type PricingType = z.infer<typeof pricingTypeSchema>
// Resource types
export type BillingPlan = z.infer<typeof billingPlanSchema>
export type Subscription = z.infer<typeof subscriptionSchema>
export type Invoice = z.infer<typeof invoiceSchema>
// Input types
export type CheckoutInput = z.infer<typeof checkoutInputSchema>
export type CancelSubscriptionInput = z.infer<typeof cancelSubscriptionInputSchema>
This ensures types and runtime validation are always in sync. If you change the schema, the type updates automatically.
Shared Schemas
Common schemas that multiple features use live in common/:
import { z } from 'zod'
/**
* Pagination meta following CONTRACTS.md §2
*/
export const paginationMetaSchema = z.object({
currentPage: z.number(),
lastPage: z.number(),
perPage: z.number(),
total: z.number(),
})
/**
* Factory for paginated responses
*/
export function paginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
data: z.array(itemSchema),
meta: paginationMetaSchema,
})
}
/**
* Standard error response
*/
export const errorResponseSchema = z.object({
message: z.string(),
errors: z.record(z.array(z.string())).optional(),
})
export const moneySchema = z.object({
amountCents: z.number().int(),
currency: z.string().length(3),
})
export type Money = z.infer<typeof moneySchema>
Features import these shared schemas:
import { moneySchema } from '@common/types/money'
import { paginatedSchema } from '@common/api/schema'
// Use in feature schemas
export const paginatedInvoicesSchema = paginatedSchema(invoiceSchema)
Validation in API Modules
API modules validate responses immediately after receiving them. If the response does not match the schema, a ZodError is thrown:
async function listMembers(): Promise<TeamMember[]> {
const response = await get<{ data: unknown[] }>(
tenantPath('/team/members')
)
return teamMemberListSchema.parse(response).data
}
async function getStats(): Promise<TeamStats> {
const response = await get<{ data: unknown }>(
tenantPath('/team/stats')
)
return teamStatsSchema.parse(response.data)
}
The pattern is consistent:
- Call the API via
useApiClient()(which handles auth, CSRF, case transforms) - Parse the response with the Zod schema (
.parse()) - Return the typed, validated result
Input validation follows the same pattern — validate before sending:
async function createInvitation(input: CreateInvitationInput): Promise<Invitation> {
const validated = createInvitationInputSchema.parse(input)
const response = await post<{ data: unknown }>(
tenantPath('/team/invitations'),
validated
)
return invitationSchema.parse(response.data)
}
Adding a New Schema
When you add a new API resource, follow this checklist:
- Add the schema to
schemas.tswith camelCase field names - Add the type to
types.tsusingz.infer<typeof schema> - Export both from
index.ts - Use
.parse()in the API module to validate responses - Reference backend sources in JSDoc comments:
/**
* Subscription status enum matching backend SubscriptionStatus
* @see Domain/Billing/Enums/SubscriptionStatus.php
*/
export const subscriptionStatusSchema = z.enum([
'active', 'trialing', 'past_due', // ...
])
What's Next
- API Contracts — The full contract system: how backend Resources map to frontend schemas
- Composables & Stores — How validated data flows into reactive state
- Vertical Slices — Feature module organization
Composables & Stores
Pinia stores with composition API, the facade pattern, useAuthenticatedAsyncData, and real examples from billing and team features.
API Contracts
The contract system bridging Laravel Resources (backend) and Zod schemas (frontend): casing, money, dates, pagination, errors, and the full endpoint flow.