Resource identifiers and client-generated ids¶
Every JSON:API resource carries a top-level id. This page shows where that id
comes from on read, how to point it at a non-default source column or constrain
its format, and how to decide between server-generated and client-generated ids
on create.
Id is usually implicit¶
You rarely declare an id at all. A resource that lists an Id field with no
source — Id::make() — reads the id property (or getId() accessor) of the
domain object and renders it as the resource's top-level id. That is the whole
common case, and it is what AlbumResource
does:
public function fields(): array
{
return [
Id::make(),
Str::make('title')->required()->maxLength(200)->sortable(),
// …
];
}
GET /albums/2 then renders "id": "2" read straight from the seeded album.
There is no implicit fallback: omit Id::make() entirely and the resource has no
id field, so it renders an empty top-level id and applies no id on create. The
only default at play is that Id::make() — when you do declare it — defaults its
source to the id column. Declaring it is the habit that pays off the moment you
need any of the levers below.
When to declare one¶
Reach for an explicit Id field in two situations:
- The id lives in a non-default column. Pass a name to
make()and the id is read from that property instead ofid:Id::make('uuid')renders the top-levelidfrom the object'suuidproperty (orgetUuid()). - You want a format rule. A client-generated id (see below) should be constrained so a malformed client value is rejected before it reaches storage.
Id is special-cased: although it is declared alongside attribute fields, it is
rendered into the resource's top-level id, never into attributes, and it is
always serialized as a string — a numeric 2 becomes "2" on the wire.
Format helpers for a client-generated id¶
The Id field carries four format shortcuts. Each appends the matching
constraint, used to validate a client-supplied id on create, and sets the
route {id} requirement so a malformed id in a URL is rejected at routing (a
404) before any handler runs:
| Helper | Constraint added | Route {id} pattern |
Use |
|---|---|---|---|
uuid(?int $version = null) |
RFC 4122 UUID format (optionally pinned to a version) | the UUID regex | UUID ids |
ulid() |
ULID format (26-char Crockford base32, case-insensitive) | the ULID regex | ULID ids |
numeric() |
pattern ^[0-9]+$ |
[0-9]+ |
digit-only ids |
pattern(string $regex) |
the regex you pass | $regex with any leading ^ / trailing $ stripped |
any custom format |
The constraint side keeps the anchored ECMA-262 form JSON Schema requires; the route side is the inner regex a Symfony route requirement expects (Symfony anchors it). One call governs both.
Setting only the route pattern: matchAs()¶
To constrain the URL {id} without adding a create-id format constraint — for an
id that is server-generated but still has a known shape — call
matchAs(string $pattern). It stores the inner route regex (no surrounding
^…$) read back via routePattern(); the framework integration applies it as the
{id} route requirement. The format shortcuts call matchAs() for you (a later
shortcut does not overwrite an explicit matchAs()).
PlaylistResource
declares a UUID id:
public function fields(): array
{
return [
// A client-generated UUID id: allowClientId() opts in so a POST may carry
// its own `id` (a default resource rejects one).
Id::make()->uuid()->allowClientId(),
// …
];
}
These format constraints only matter when a client supplies the id. They are part of the broader field constraint vocabulary — see fields.
Where a create's id comes from¶
Two orthogonal axes on the Id field decide a created resource's id. Both have a
default, so a plain Id::make() is the common case: a client id is rejected, and
the store assigns the id.
Axis 1 — client-id acceptance (default: forbidden):
| Call | A client data.id is… |
|---|---|
| (default) | rejected with 403 ClientGeneratedIdNotSupported |
allowClientId() |
optional — used when supplied (validated against the format), otherwise the Axis 2 fallback applies |
requireClientId() |
mandatory — its absence is 403 ClientGeneratedIdRequired |
Read with allowsClientId() / requiresClientId().
Axis 2 — the fallback when the client supplies none (default: store-provided):
| Call | When no client id is supplied… |
|---|---|
| (default) | core sets nothing — the persister/DB assigns the id (an auto-increment column, a database default) |
generated() |
core mints one from the declared format: uuid() → a v4 UUID, ulid() → a Crockford-base32 ULID |
generateUsing(fn) |
core mints one by calling your closure (it returns the storage key directly) |
generated() only works on a self-generating format — declare uuid() or ulid()
first. generated() on numeric(), pattern(), or no format is a configuration
error (a \LogicException raised when the field is declared). For anything else,
use generateUsing().
Store-provided is the default¶
With a plain Id::make() and no client id, core sets no id and the store assigns
one. This is the natural fit for an auto-increment integer key:
// The resource just declares Id::make(); the entity's id column is a DB-assigned
// auto-increment.
$response = $this->post('/widgets', [
'data' => ['type' => 'widgets', 'attributes' => ['name' => 'Sprocket']],
]);
// 201; response data carries the DB-assigned id, e.g. "id": "42".
The id is read back off the persisted entity for the 201 body and the Location
header, so a store-assigned id round-trips exactly like any other.
App-generated ids¶
Call generated() (or generateUsing()) when the application should mint the id
rather than the database — typically a UUID or ULID primary key. AlbumResource
declares Id::make()->uuid()->generated(), so a POST with no id is given an
app-minted UUID:
$response = $this->post('/albums', [
'data' => [
'type' => 'albums',
'attributes' => ['title' => 'Hail to the Thief'],
],
]);
// 201, response data carries a non-empty app-generated UUID `id`.
This is the witness in IdsTest
(aServerGeneratedIdIsAssignedWhenNoneIsSupplied). A ulid()->generated() field
mints a lexicographically-sortable ULID instead; generateUsing(fn) gives you full
control over the minted storage key.
Client-generated ids¶
By default a resource rejects a client-supplied id — the spec lets a server
do so. Supplying an id on a create yields a 403
ClientGeneratedIdNotSupported
(pointer /data/id). Posting an id to albums, which does not opt in, is a
403:
$response = $this->post('/albums', [
'data' => [
'type' => 'albums',
'id' => '99999999-9999-4999-8999-999999999999',
'attributes' => ['title' => 'Amnesiac'],
],
]);
// 403 ClientGeneratedIdNotSupported
To honour client ids, call allowClientId() on the Id field — that is the whole
difference between AlbumResource and PlaylistResource:
// Accept a client-supplied UUID id on create; fall back to store-provided when
// none is given.
Id::make()->uuid()->allowClientId();
With the opt-in in place a POST that carries its own UUID is honoured verbatim:
the created resource is rendered at exactly that id and the Location header
points at the client-chosen value, not a server-minted one. The same id is then
readable directly:
$response = $this->post('/playlists', [
'data' => [
'type' => 'playlists',
'id' => 'a1a2a3a4-b1b2-4c3c-8d4d-e1e2e3e4e5e6',
'attributes' => ['title' => 'Late Night', 'public' => true],
],
]);
// 201; Location: https://music.example/playlists/a1a2a3a4-b1b2-4c3c-8d4d-e1e2e3e4e5e6
$fetched = $this->get('/playlists/a1a2a3a4-b1b2-4c3c-8d4d-e1e2e3e4e5e6');
// 200, same id, readable at the client-chosen id.
To make a client id mandatory — a natural key the client owns, with no
server-side fallback — call requireClientId(): a create without an id is then a
403 ClientGeneratedIdRequired.
The client-id exceptions¶
| Exception | Status | Code | When |
|---|---|---|---|
ClientGeneratedIdNotSupported |
403 |
CLIENT_GENERATED_ID_NOT_SUPPORTED |
a client id was supplied but the resource does not accept one (allowClientId() not set) |
ClientGeneratedIdRequired |
403 |
CLIENT_GENERATED_ID_REQUIRED |
requireClientId() is set but no id was supplied |
ClientGeneratedIdAlreadyExists |
409 |
CLIENT_GENERATED_ID_ALREADY_EXISTS |
the supplied id collides with an existing resource (raised by the data layer) |
All three carry an ErrorSource pointer of /data/id. The format helpers above
catch a malformed client id; these exceptions cover policy (not supported,
required, already taken). For how errors are shaped and rendered, see
errors.
Encoding: when the wire id differs from the storage key¶
Sometimes the id a client sees is a transform of the value the entity actually
stores — a binary UUID exposed as a string, an integer primary key obscured
behind a reversible codec (hashids), and so on. Attach an
IdEncoderInterface with encodeUsing() and the Id field becomes the wire
form of a distinct storage key:
interface IdEncoderInterface
{
public function encode(mixed $storageKey): string; // storage -> wire
public function decode(string $wireId): mixed; // wire -> storage; null when undecodable
}
The entity always holds the storage key. Core drives the entity's own id transform across both directions of the lifecycle:
- On serialize the stored key is
encode()d, so every renderedid(the top-level member and any id-as-field) is the wire form. - On create with a client id the wire id is
decode()d to the storage key and that is set on the new entity — so a created entity holds the storage key, exactly like a read entity, and its rendered id round-trips. A well-formed id thatdecode()rejects (null) is a422ResourceIdUndecodable(pointer/data/id); the format constraint above already catches a malformed id before hydration, sodecode()only ever runs on a well-formed value — thenullbranch is the safety net. An app-generated id (generated()/generateUsing(), no client id) is a storage key already and is set as-is, never decoded — it is the storage key's own wire form, not the encoder's input, so feeding it todecode()would reject every minted create. A store-provided id is never set by core at all. Update (PATCH) never sets the id, so it does not decode.
A type with no encoder behaves exactly as before: wire == storage, nothing is
transformed. The id-as-lookup-key transforms — decoding the route {id} before a
database find, and decoding linkage ids in relationship writes — live in the
framework integration's data layer, not in core, because they flow through the
provider/persister as wire strings; see the Symfony bundle's data-layer docs.
lid is a separate concern¶
A request may reference a not-yet-created resource by a local id (lid) inside a
single document. That is resolved during request processing, not by the Id
field, and is covered in concepts.
Id vs the Uuid attribute field¶
Do not confuse the two. Id produces the resource's top-level id;
Uuid::make('externalId') is an ordinary attribute that happens to validate
UUID format (it is sugar for Str::make('externalId')->uuid()) and renders inside
attributes. PlaylistResource uses both: Id::make()->uuid() for the resource
identifier and Uuid::make('externalId')->nullable() for a separate UUID-valued
attribute. Choose Id for the identity of the resource and Uuid (or any
attribute field) for a UUID-shaped value that merely belongs to it.