# REST API design

Applies when: something you cannot deploy consumes your endpoints — a mobile app, a partner
integration, a front-end that ships on its own schedule. The distinguishing constraint is that
**you cannot fix the client**. Everything follows from that.

## The contract

### Shape stability beats elegance

Once a client parses your response, the shape is frozen in their code. Renaming `created_at` to
`createdAt` because camelCase is tidier nulls out a field in an app three App Store review cycles
away from being fixed. The failure mode is silent: clients rarely crash on a missing field, they
render a blank or default to zero, and nobody notices until a customer reports a price of 0.00.

| Change | Safe? | Why |
|---|---|---|
| Adding a field | Yes | Unknown keys are ignored by every sane parser |
| Adding an optional request param | Yes | Old clients omit it, get old behaviour |
| Making a nullable field non-null | Yes | Narrowing what you send is fine |
| Adding an enum value | **No** | Client `switch` statements have no branch for it |
| Changing a type (`"42"` → `42`) | **No** | Strict parsers throw, loose ones coerce wrong |
| Renaming a field | **No** | This is a delete plus an add |
| Making a non-null field nullable | **No** | Client dereferences null |
| Tightening validation | **No** | Payloads that worked yesterday now 422 |

The type change is the one that ships by accident. An ID that was a string because it came from a
`char(36)` column becomes an integer after a migration, and every client doing `id === "abc"`
silently stops matching. Cast at the serialisation boundary, and never return a model directly —
`return $order;` publishes the next migration's `internal_margin` column to every consumer.

```php
return [
    'id'     => (string) $this->id,   // string forever, whatever the column becomes
    'total'  => (int) $this->total_cents,
];
```

### Versioning: pick one, for a reason

**URI** (`/api/v1/orders`) is ugly, obvious, cacheable and trivially routable — the right default
for public APIs, because a developer can see the version in a log line. **Header** (`Accept:
application/vnd.acme.v2+json`) buys clean URLs and a bug magnet: the client that forgets the header
silently gets v1 behaviour, so treat a missing version header as an error, never as "latest". **No
versioning, additive only** is legitimate when you control every consumer. What does not work is
versioning per-endpoint, so `/v1/orders` and `/v3/customers` coexist — nobody can reason about that
after six months, including you.

Version the **contract**, not the code: `v2` is a transformation layer over one implementation, not
a copy-pasted controller directory, because two forked trees means security fixes land in one of
them. And deprecate on a clock — an announced removal with no date never happens. Send
`Deprecation` and `Sunset` headers, log every call with the caller's identity, and find the
integrations still using it before you switch it off.

### Pagination is part of the contract

**Offset** (`?page=3`) supports jump-to-page, breaks under concurrent writes — an insert shifts
every row, so page 2 re-shows a row from page 1 — and `OFFSET 50000` makes the database count
50,000 rows to discard them. **Cursor** (`?after=eyJpZCI6MTIzfQ`) is stable under writes and
constant-time at any depth, with no jump-to-page and no total count: correct for anything
unbounded. Either way:

1. **Always paginate collections.** `GET /api/users` at 40 rows in staging is the same call at
   400,000 rows in production, and the client doing `.map()` over it now times out. Cap
   `per_page` server-side too: `per_page=100000` is a denial of service you built yourself.
2. **Never change the wrapper.** If it is `{"data": [...], "meta": {...}}`, it is that when empty
   too. A bare `[]` on empty breaks every client that types the response.
3. **Sort deterministically.** Without an `ORDER BY` on a unique column, rows appear on two pages
   or none. Ties need a tiebreaker: `ORDER BY created_at DESC, id DESC`.

## Security

### Object-level authorization (OWASP API #1)

Authentication answers "who", authorization answers "may they". Conflating them is the most common
API vulnerability: a valid token proves the request came from *someone*, and says nothing about
whether that someone may touch *this record*. The failure: `GET /api/invoices/1041` returns the
invoice, the caller is authenticated so middleware waves it through, and the invoice belongs to
another customer. There is no exploit and no payload — someone changed a number in a URL. It is the
most-exploited API flaw in existence because it needs no skill and its traffic looks like normal
use. Make the unsafe query impossible to write: scope so "forgot the check" is a 404, not a leak.

```php
$invoice = Invoice::findOrFail($id);                       // authenticated, not authorized
$invoice = $request->user()->invoices()->findOrFail($id);  // row does not exist off-tenant
```

Calling `$this->authorize('view', $invoice)` in every method works right up until the method where
it is missed, and nothing in a test suite notices an *absent* check. Prefer scoping that fails
closed; use policies as a second layer, not the only one. Sequential integer IDs also make
enumeration trivial — `for id in 1..100000` walks the table. UUIDs do not fix the authorization bug,
only slow its exploitation; fix the authorization, then use UUIDs anyway.

### Never trust a client value that has consequences

Prices, roles, tenant IDs, user IDs, statuses and totals are **server-derived**, always:

```php
Order::create($request->all());        // the client sets the price
Order::create($request->validated());  // validated — but role and user_id still came from the client

$order = $request->user()->orders()->create([   // client picks what to buy,
    'product_id'  => $validated['product_id'],  // the server decides what it costs
    'total_cents' => $product->price_cents * $validated['quantity'],
]);
```

Mass assignment is worse over an API than over a form, because no rendered field list bounds what a
caller thinks to send. An attacker posts `{"role": "admin"}` to a profile endpoint on the chance
that `$fillable` is generous. Enumerate what you assign; never pass an unfiltered array to create.

### Tokens, rate limits, CORS

Scope tokens — one minted for a mobile app should not reach admin endpoints. Expire them; a
non-expiring token is a permanent credential living in an app bundle, a CI log and a Postman
collection shared in Slack. Hash them at rest, or one database read becomes account takeover for
every user. Never accept them in a query string, which lands them in access logs and `Referer`
headers. Support rotation without downtime: if replacing a leaked credential means taking the
integration offline, it will not happen promptly — the only thing that matters after a leak.
Rate limit per authenticated identity, not per IP; IP limiting punishes everyone behind one NAT
and does nothing against a distributed caller. Limit login, password reset and signup harder than
the rest, and return `429` with `Retry-After` — a limit that gives no indication of when to come
back produces a hot retry loop, the exact traffic you were shedding. Finally,
`Access-Control-Allow-Origin: *` with `Allow-Credentials: true` lets any page the user visits read
their authenticated responses, and reflecting the request's `Origin` back is the same hole with
extra steps. Enumerate allowed origins — and note CORS protects browsers, not you. It is
irrelevant to curl, so it is never authorization.

## Errors and status codes

`200 OK` with `{"error": "not found"}` forces every client to parse a body to discover failure, so
most will not and will treat the error as data. Retry logic, circuit breakers, monitoring and CDN
caching all key off the status code — give them one that is true.

- `400` malformed request — unparseable JSON
- `401` not authenticated — credentials missing, invalid, or expired
- `403` authenticated but not permitted
- `404` not found, **or** exists but is none of your business
- `409` state precludes this — already refunded, version mismatch
- `422` well-formed but semantically invalid
- `429` rate limited, always with `Retry-After`
- `500` you broke — never for a client's bad input
- `503` dependency down or shedding load, with `Retry-After`

`403` versus `404` is a real decision: `403` on a record the caller cannot see confirms it exists,
which leaks. Return `404` cross-tenant; reserve `403` for resources the caller already knows about.

### One envelope, and nothing leaking through it

```json
{ "error": { "code": "insufficient_funds", "message": "Card declined.", "request_id": "01JQ8F2K" } }
```

Clients branch on `code`, a stable machine-readable string that never changes. `message` is for
humans and may be reworded freely — a client matching on message text breaks when you fix a typo. A
stack trace in a 500, meanwhile, hands over your framework version, file paths, class names and
often a query fragment with real data; `APP_DEBUG=false` in production is not a preference. Equally:
no internal IDs, no constraint names, no vendor strings verbatim. `SQLSTATE[23000]: Duplicate entry
'a@b.com' for key users_email_unique` confirms an account exists and names your schema. Return
`{"code": "email_taken"}` and log the detail against the `request_id` — that ID is the difference
between a ticket you resolve in one query and one starting with "which request?".

### Idempotency, and what a client may retry

`GET`, `PUT` and `DELETE` are idempotent by definition — honour it. `DELETE` on an already-deleted
resource is `204`, not `404`; the caller's desired state is achieved. `POST` is not, and a timeout
does not tell the client whether the write landed. Accept an `Idempotency-Key` on every
state-changing `POST` that costs money, store the key with its response, and replay on repeat:

```php
if ($existing = IdempotentRequest::find($key)) {
    return response()->json($existing->response, $existing->status);
}
// otherwise perform the write under a lock on $key, then persist key + response
```

Without it, a client retry policy plus a slow endpoint equals duplicate charges — and the client is
not doing anything wrong by retrying a timeout, it genuinely does not know. Then say what is
retryable: retrying a `422` fails identically forever, retrying a `500` might work, and `503` plus
`Retry-After` is an instruction where a bare `500` is a guess. Never return `200` for async work
that has not happened — `202 Accepted` with a status URL tells the truth, while `200` tells the
client the work is done, and it will act accordingly.

## Documentation

An API's real documentation is what it does when called. Where prose and behaviour disagree,
consumers believe the behaviour — after losing an afternoon to the prose. A spec written after the
code is stale within a sprint, because nothing fails when they diverge. Two arrangements survive.
**Spec-first**: the spec is authored and contract tests assert the implementation conforms, so
drift fails CI. **Code-first with generation**: the spec is generated from resources, form requests
and routes and committed, so an unintended diff in a PR is an unintended breaking change, visible
before merge. A hand-maintained `api.md` will be wrong. Document per endpoint: auth requirements
and scopes, every error `code` it can return, rate limits, pagination style. Error codes are the
most-omitted and most-needed — a client cannot handle a failure it has never heard of.

### Comment the why, not the shape

The shape is in the spec. Comments earn their place by recording what the spec cannot: why a field
exists, who depends on it, and what breaks if it changes.

```php
/**
 * Legacy field. The v1 mobile client reads `full_name` and crashes on null.
 * Removable once v1 traffic hits zero — tracked on the deprecation dashboard.
 */
'full_name' => trim("{$this->first_name} {$this->last_name}"),
```

Without that comment the next engineer deletes the line during a tidy-up, correctly, and breaks a
client they did not know existed. Document the constraints invisible in the schema: which fields are
server-derived and must never accept client input, why a limit is 100 rather than 1000, which enum
values a partner depends on. Anything a reviewer could plausibly simplify needs a sentence saying
why it is not to be.

## The one rule above all others

**You cannot recall a response you have already returned.** Every shape, field name, type and status
code you ship is yours to support until every consumer stops calling it. Design it as though it is
permanent, because in practice it is.

---
MIT licensed. Written by Smit Desai — <https://laravel.org.in>
