API Conventions
What this covers / who it's for. The contract every
/api/v1surface honors: the JSON:API envelope and error vocabulary, URL versioning, the bigint-internal/UUIDv7-public id rule, the two idempotency layers, edge protection, and how the OpenAPI docs stay true. For the concrete builder classes and controller recipes, defer to the skill reference.
One envelope, everywhere
Every API response — client SPA (/api/v1/*) and operator backoffice (/api/v1/backoffice/*)
alike — is a JSON:API document (application/vnd.api+json) built by one of exactly three
Foundation builders: JsonApiResource (single entity), JsonApiCollection (lists, with
JSON:API-shaped pagination for length-aware, simple, and cursor paginators), or
JsonApiEnvelope (non-entity payloads). All three attach the same meta block —
request_id, correlation_id, timestamp — stamped by the correlation middleware
(AssignCorrelationId) into Context, so the response meta, the response headers, the bus
audit lines, and every log record join on the same ids. Controllers never hand-roll JSON; they
validate, dispatch through the bus, and wrap the returned DTO (see cqrs-bus.md).
The error contract
Errors are exceptions, rendered centrally by JsonApiExceptionHandler into
{ errors: [...], meta }. The stable machine surface is the ErrorCode vocabulary
(Modules\Foundation\Enums\ErrorCode, a string-backed enum of 70+ codes — STEP_UP_REQUIRED,
ACCOUNT_FROZEN, RATIONALE_REQUIRED, VALIDATION_ERROR, …): clients branch on errors[].code,
never on prose. Domain rules throw Modules\Foundation\Exceptions\DomainException, which carries
the ErrorCode plus optional JSON:API source (e.g. a pointer into the request document) and
meta members that the handler renders onto the error object (D75). Validation errors keep the
source.pointer = /data/attributes/<field> shape; unexpected throwables render a generic 500
with the real message leaked only in debug mode.
Versioning and route shape
Versioning is URL-based: everything lives under /api/v1, and a breaking change means a new
api/v2 prefix group — v1 is never broken in place. Routes are owned by modules
(auto-loaded by internachi/modular) in three flavors: central groups (api.v1. — discovery,
registration), tenant groups (tenant.api.v1. — Sanctum stateful + X-Tenant tenancy
stack), and backoffice groups (backoffice.api.v1. — Sanctum stateful + auth:web, no
tenancy). The middleware order within each is load-bearing — see
architecture-flow.md §2 and auth-and-rbac.md.
Identifiers on the wire (D25)
The API speaks UUIDv7 only. Internally every entity keys on a bigint PK (fast joins, the RLS
session variable); externally the DB-generated uuid column is the id — in URLs (route binding
resolves on uuid), in JSON:API id, and in every *_id attribute a DTO exposes (a
tenant_id field on the wire carries the tenant's uuid, mapped in the DTO's fromModel()).
A bigint in an API payload is a bug the review must catch; the schema/model guards enforce the
column side (see data-model.md).
Idempotency — two distinct layers (D28/D56/D74)
Retryable writes are protected twice, at different altitudes. The layers are complementary, not redundant — the HTTP layer dedupes client retries of an endpoint, the bus layer dedupes the domain operation itself (including webhook-driven and internal dispatch):
| HTTP edge (D74) | Bus (D28/D56) | |
|---|---|---|
| Opt-in | Idempotency-Key request header on routes carrying the idempotency-key middleware (EnforceClientIdempotency) | the command class implements Idempotent |
| Store | client_idempotency_keys — tenant-scoped (RLS), keyed (tenant, actor, key, route) | command_dedup_records — keyed on the command's dedup identity |
| Replays | the stored HTTP response | the stored handler result |
| On failure | the claim is released (the client may retry) | the transaction rollback removes the claim |
Edge protection: throttles and Turnstile
Every route group declares a throttle (10,1 on the unauthenticated central endpoints, 120,1
on the tenant group, 60,1 on the backoffice group), and every unauthenticated,
abuse-attractive endpoint — tenant discovery, workspace registration, both login routes —
additionally carries the turnstile middleware (VerifyTurnstile, Cloudflare Turnstile):
email-first discovery makes email enumeration inherent, so the flow rate-limits and
bot-gates instead of pretending otherwise (D16/D18). Fine-grained auth limiters (login 5/min per
email+IP, MFA challenge attempt caps) live in the auth backends — see
auth-and-rbac.md.
OpenAPI that stays true (Scramble)
Docs are generated from the routes and code by dedoc/scramble, so the spec cannot drift from
the middleware or the DTOs — but only if authors feed it. The app registers two APIs in
FoundationServiceProvider::registerApiDocumentationSurfaces():
| API | Scope | UI / JSON |
|---|---|---|
client | /api/v1/* except backoffice | /docs/api/client, /docs/api/client.json |
backoffice | /api/v1/backoffice/* | /docs/api/backoffice, /docs/api/backoffice.json |
Foundation's Scramble extensions make the CQRS indirection documentable (they infer
CommandBus::dispatch()/QueryBus::ask() return types from the handler), render DTOs and the
JSON:API envelope, and mirror the error contract. The author's share of the deal, per action:
a PHPDoc summary, a controller #[Group], a #[DomainError] for every
handler/gate-thrown code (Scramble only sees the controller, never the handler), and
#[SupportsIdempotencyKey] on idempotent endpoints. Security documentation is derived from
middleware (auth:* → cookie-secured), so getting the route group right is the security doc.
Export with php artisan scramble:export / make openapi-export and skim the diff — an empty
summary or a wrong error code is a doc bug. Full workflow: the scramble-development skill
(skills package).
Going deeper
- Builder classes, pagination shapes, DTO mapping, complete controller examples: the api-and-json-api skill reference.
- The bus the controllers call: cqrs-bus.md; guard/middleware stacks: auth-and-rbac.md.
- Foundation module specifics (middleware, envelope, error machinery):
app-modules/foundation/README.md. - Decision rationale: D25, D28, D56, D74, D75 in the decision log.