Skip to content

Backend–Frontend Data Contract ​

Purpose ​

This rule defines the only accepted way to exchange data between the Laravel backend and the Vue 3 + TypeScript frontend.

The backend response, frontend type, and frontend usage must represent one identical contract. When they disagree, fix the contract at its source. Never repair, infer, normalize, cast, default, or support multiple response shapes at the consumer.

A visible failure is preferable to silently displaying incomplete or incorrect data.

When to apply ​

Apply this rule to:

  • Backend API requests and responses.
  • Show, detail, edit, create, update, and list endpoints.
  • Eloquent models and relations returned by APIs.
  • DTOs, PHPDocs, validation requests, and response types.
  • Frontend API services and canonical TypeScript response types.
  • Vue components, stores, composables, and forms that consume API data.
  • HTTP interceptors and shared API clients.

Rules ​

1. Backend and frontend share one exact contract ​

The TypeScript type must exactly match the JSON returned by the backend, including:

  • Field names and object structure.
  • Relation and array structure.
  • Required and nullable fields.
  • Primitive types and enum values.
  • Date and time formats.

If the frontend needs a field, return or load it from the backend. Do not create the missing field at the frontend call site.

ts
// Forbidden
const companyName = response.company_name ?? response.company?.name

// Required
const companyName = response.company.name

2. Return real objects and nested relations ​

Detail endpoints must return the main object with its required related objects.

php
$user = User::query()
    ->with([
        'company',
        'posts',
    ])
    ->findOrFail($id);

return $user;

Expected response:

json
{
  "id": 42,
  "name": "Anna",
  "company": {
    "id": 7,
    "name": "Example Company"
  },
  "posts": [
    {
      "id": 101,
      "title": "First Post"
    }
  ]
}

Frontend code reads nested fields directly:

ts
user.company.name
user.posts[0].title

Do not duplicate relation data as root-level aliases such as company_name or first_post_title. The same fact must have one authoritative location in the response.

3. Define contracts per API operation ​

Request and response data are separate contracts because they serve different operations.

ts
interface UserDetailResponse {
  id: number
  name: string
  company: CompanyResponse
}

interface UpdateUserRequest {
  name: string
  company_id: number
}

A list endpoint may use a limited projection when table performance requires it. Give that response its own exact type, such as UserListItemResponse.

List contracts must not be reused or cast as detail contracts. Detail, view, and edit flows must fetch their detail endpoint.

ts
// Forbidden
selectedUser.value = users[index] as UserDetailResponse

4. Keep every endpoint response deterministic ​

The same endpoint must not return different structures based on query parameters, user roles, callers, execution branches, missing values, or whether a relation happened to be loaded.

Required keys are always present. Nullable values use null instead of an omitted key. Collections always use arrays, including empty arrays.

json
{
  "company": null,
  "posts": []
}

A collection must never alternate between an array, null, an object, or a missing key. Primitive types must also remain stable; do not mix numbers with numeric strings or booleans with 0, 1, "true", or "false".

5. Do not reshape responses on the frontend ​

Frontend code must pass the original response object through directly. Do not reconstruct, rename, flatten, expand, or normalize it.

ts
// Forbidden
const user = {
  ...response,
  company_name: response.company.name,
}

// Required
userStore.current = await getUser(id)

Normalizers, adapters, and mapping helpers that create a second response shape are forbidden. Responses from multiple endpoints must not be merged into one fake entity.

UI code may derive values for presentation, but it must not mutate the API object.

ts
const createdAtText = formatDate(user.created_at)

// Forbidden
user.created_at = formatDate(user.created_at)

6. Do not add compatibility fallbacks ​

Frontend and backend code must not support old and new response shapes at the same time.

ts
// Forbidden
const companyName =
  response.company?.name ??
  response.company_name ??
  response.customer_name ??
  '-'

Do not inspect legacy keys with in, Object.hasOwn, dynamic property access, or generic path helpers. Do not temporarily return duplicate old and new fields.

When a contract changes, update the backend and frontend together and remove the old shape immediately.

7. Required data must fail fast ​

Required fields and relations must be accessed as required data. Optional chaining and fallback values are allowed only when the contract explicitly declares a value nullable.

ts
interface UserDetailResponse {
  company: CompanyResponse
  phone: string | null
}

user.company.name
const phoneText = user.phone ?? '-'

Do not hide missing required data with optional chaining, nullish coalescing, logical OR, empty arrays, empty objects, zero, or placeholder strings.

Use an explicit loading state instead of a fake initial object:

ts
const user = ref<UserDetailResponse | null>(null)

8. Do not bypass or duplicate response types ​

API contract code must not use unsafe TypeScript escapes:

ts
response as UserDetailResponse
response as unknown as UserDetailResponse
{} as UserDetailResponse
response!
Record<string, any>
Record<string, unknown>

Do not weaken a response with Partial, Pick, or Omit unless the backend has an endpoint returning that exact narrower contract.

Do not redefine response interfaces inside components, stores, composables, pages, services, or utilities. Use the canonical shared type. Different names are allowed only for genuinely different backend contracts.

9. HTTP clients handle transport only ​

Axios, Fetch wrappers, interceptors, and shared API clients may handle authentication, status codes, cancellation, common envelopes, and network errors.

They must not:

  • Convert between snake case and camel case.
  • Rename keys or flatten relations.
  • Add aliases or default values.
  • Remove, merge, or normalize business fields.
  • Convert one response contract into another.

Network, permission, validation, and contract errors must enter the error flow. Do not catch them and return null, {}, [], or other valid-looking business data.

10. Load backend relations explicitly ​

Required relations must be declared in the backend query. Do not depend on lazy loading during serialization.

php
$user = User::query()
    ->with([
        'company',
        'posts',
    ])
    ->findOrFail($id);

Development and test environments should prevent lazy loading:

php
Model::preventLazyLoading();

Backend serialization must not rename keys, flatten relations, add compatibility fields, or supply defaults for missing required data.

Required Eloquent relations must not use withDefault() to hide missing related records. Broken required relationships must fail instead of becoming empty related objects.

Review checklist ​

  • The endpoint has exactly one deterministic response shape.
  • The frontend type exactly matches the backend JSON.
  • Required keys are present, nullable values use null, and collections are arrays.
  • Primitive types, enums, dates, and relation structures remain stable.
  • Detail responses use explicitly eager-loaded nested relations.
  • List contracts are not reused for detail, view, or edit flows.
  • No aliases, response remapping, normalizers, or compatibility fallbacks exist.
  • No optional chaining, defaults, fake objects, or unsafe casts hide missing required data.
  • Canonical response types are shared rather than redefined locally.
  • HTTP clients preserve business payloads and errors remain errors.
  • Backend and frontend are updated together for every contract change.

Internal engineering documentation