Skip to content

Fields: the shared builder surface

A field is one entry in a resource's fields() list. Each field describes one member of a resource type — the id, an attribute, or a relationship — and that single declaration drives both directions: how the member is serialized out of a domain object and how it is hydrated back into one. This page documents the fluent surface that every attribute field builder inherits from Resource\Field\AbstractFieldBuilder. Each concrete field type (field types) adds only its type-specific delta on top.

use haddowg\JsonApi\Resource\Field\Id;
use haddowg\JsonApi\Resource\Field\Str;

public function fields(): array
{
    return [
        Id::make(),
        Str::make('title')->required()->maxLength(200)->sortable(),
    ];
}

Each make() returns a field builder — a mutable object whose fluent methods mutate it and return it, so a field reads as one chained expression. The resource builds each builder into a readonly Field value object before use, and one fields() entry configures serialize and hydrate at once. Autocomplete on the builder shows only authoring methods; the built Field exposes only the consumption surface.

Naming and storage

You always construct a field through its static make() factory, never with new. The first argument is the JSON:API member name; by default that name is also the backing domain-object member.

Method Effect
make(string $name) Constructs the field. The member name is also the default backing column.
storedAs(string $column) Reads/writes a different domain-object member than the JSON:API member name.
computed() Marks the field as having no backing column. Pair with extractUsing() for the read value.
computedUsing(\Closure $cb) One-liner for a derived, read-only attribute: computed() + the value closure + readOnly().
on(string $path) Flattens a scalar attribute from a chain of declared, to-one relations' related model ('author' or 'publisher.country').

TrackResource exposes a durationSeconds member backed by a differently named column, and a displayTitle member with no column at all:

// The JSON:API member `durationSeconds` is stored on the domain object's
// `length_seconds` column — the rename round-trips through serialize and hydrate.
Integer::make('durationSeconds')->storedAs('length_seconds'),

// Computed: no backing column, derived on read via extractUsing().
Str::make('displayTitle')
    ->computed()
    ->readOnly()
    ->extractUsing(static fn(mixed $track): string => $track instanceof Track
        ? \sprintf('%d. %s', $track->trackNumber, $track->title)
        : ''),

Derived attributes: computedUsing()

computedUsing(\Closure $cb) is the one-liner for the computed-and-read-only pattern above: it marks the field computed() (no backing column), sets the value closure, and marks it readOnly() on both create and update (a derived value has nothing to write back). The closure receives (mixed $model, $request, string $name) and owns the serialized output — no per-type cast is applied — so the two lines

Str::make('displayTitle')->computed()->readOnly()->extractUsing($fn),
Str::make('displayTitle')->computedUsing($fn),

are equivalent. The lower-level computed() / extractUsing() / serializeUsing() primitives remain available for cases the sugar does not cover (e.g. a computed field that is not read-only, or one that wants per-type casting via extractUsing).

on(string $path) flattens a scalar attribute from a chain of declared, to-one relations' related model — the JSON:API resource carries the value inline while the domain stores it on an associated record. $path is a .-separated chain of relation names: 'author' (single hop) or 'publisher.country' (multi-hop). The value is read from / written onto the final related model in the chain, using the field's own column() ?? name(). Every segment must be a declared, to-one RelationInterface; a segment may be hidden(), the idiomatic "internal association" that backs a flattened attribute without ever rendering as a relationship:

// The book's resource exposes `authorName` inline; it lives on the related
// author. The `author` relation is hidden, so it is not a rendered relationship.
Str::make('authorName')->on('author')->storedAs('name'),
BelongsTo::make('author', 'authors')->hidden(),

// A multi-hop chain: book -> publisher -> country, reading the country's `name`.
Str::make('countryName')->on('publisher.country')->storedAs('name'),
BelongsTo::make('publisher', 'publishers')->hidden(),
  • Read. The chain is walked hop by hop (each hop honouring its relation's column()/storedAs()), the field reads its own column() ?? name() off the final related object, and the normal per-type cast applies. Any intermediate null short-circuits the chain → a null attribute value.
  • Write. The value is written onto the final related model (mutated in place — your ORM's unit of work persists the dirty loaded entity on flush). A flattened attribute never auto-instantiates a related model: writing one when any hop is absent is a 422 (RelatedAttributeOwnerMissing, pointing at /data/attributes/<name>). Flattened attributes hydrate after relationships, so a first-hop relation associated in the same request body is visible.

Every segment is validated fail-loud at boot / container warm-up: an unknown segment, or a to-many segment at any depth, is a developer-facing \LogicException (a to-many is not flattenable — use ?include to materialise a collection).

on() is mutually exclusive with computedUsing() / extractUsing() (a flattened attribute reads its own backing member off the related object). For the host to materialise the chain before serializing, the resource declares it as an eager load automatically — see Eager-loading below.

Accessor resolution order

The backing member is resolved through a framework-agnostic accessor. For a member named genres on an object, the read path tries, in order:

  1. a getGenres() method,
  2. an isGenres() method,
  3. a public genres property;

and the write path tries a setGenres() method, then a public genres property. Plain associative arrays and ArrayAccess objects are addressed by key. You never wire any of this up explicitly — it is the zero-config default. ORM entities with private properties and bespoke accessors are handled by a field's extractUsing() / fillUsing() hooks (below), not by this helper.

Visibility and query eligibility

Method Effect
hidden() Drops the field from output and from hydration entirely.
writeOnly() The inverse direction: accepted on write but never rendered (see below).
notSparseField() Exempts the field from sparse-fieldset (?fields[type]=…) filtering, so it is always emitted.
sortable() Marks the field as sortable; the resource's allSorts() derives a SortByField for it.

hidden() removes the member from both directions. For the opposite asymmetry — a member a client sets but the server never echoes back, a password or an API token — reach for writeOnly(), covered next.

Write-only members

writeOnly() marks a field write-only: it is accepted on write — hydrated on both create and update, and still validated — but never rendered. The member is dropped exactly where sparse-fieldset filtering happens, so it appears on no read (single, collection, included, related) and is absent, not null: a fields[type] parameter that names it cannot resurrect it.

UserResource's password is the worked example:

// users: accepted + validated on write, never rendered — absent, not null.
Str::make('password')->writeOnly(),

A POST carrying password hydrates it into the stored object, but the 201 response (and every later GET) omits the member entirely — no "password" key.

writeOnly() and readOnly() are the two opposite asymmetries and cannot combine: a field that is neither readable nor writable is contradictory, so declaring both throws a \LogicException. Inside a Map, a write-only child is skipped on render just like a top-level write-only field, so it too is absent from the nested object.

Read-only scoping (gates hydration)

A read-only field is still serialized, but is silently skipped during hydration in the matching context — a client value in the request body is ignored, not rejected.

Method Effect
readOnly() Read-only on both create and update.
readOnlyOnCreate() Read-only on create (POST) only.
readOnlyOnUpdate() Read-only on update (PATCH) only.

AlbumResource's averageRating is server-computed: a client value in the write body is dropped, and a freshly created album keeps its domain default.

Decimal::make('averageRating')->readOnly()->nullable(),

Request-aware visibility and writability

hidden(), readOnly()/readOnlyOnCreate()/readOnlyOnUpdate() and writeOnly() each also accept an optional closure that decides the restriction per request — lightweight per-caller control without a security framework. The uniform convention: the closure returns true when the restriction applies.

// Hidden only from a non-admin caller. Model first, request second — the same
// argument order as every other field closure (extractUsing/computedUsing), so you
// never swap order between adjacent declarations.
Str::make('draftNote')->hidden(
    static fn(mixed $model, JsonApiRequestInterface $request): bool
        => $request->getHeaderLine('X-Role') !== 'admin',
),

// Write-gating predicates take only the request — a create has no persisted model.
Str::make('locked')->readOnlyOnUpdate(
    static fn(JsonApiRequestInterface $request): bool
        => $request->getHeaderLine('X-Role') !== 'admin',
),

A closure-declared field is not unconditionally restricted, so the static getters (isHidden(), isReadOnly(), isWriteOnly()) report the permissive value and the OpenAPI schema documents the superset (a sometimes-hidden field still appears; a sometimes-prohibited verb is still exposed). The relation authorization predicates cannotReplace()/cannotRemove()/cannotAdd()/ cannotBeIncluded() take the same (mixed $model, JsonApiRequestInterface $request) closure — see relations.

Presence (gates validation)

The presence helpers declare whether a member must appear, and whether it may be null. They are validation metadata, scoped by request context. On a PATCH, an absent member means "no change" — so required() and requiredOnCreate() differ only on update.

Method Adds
required() Required on both create and update.
requiredOnCreate() Required on create (POST) only; absent on PATCH means "no change".
requiredOnUpdate() Required when supplied on update (PATCH) only.
nullable() Nullable — the member may be null.
Str::make('title')->required()->maxLength(200),
Date::make('birthDate')->nullable(),

Two axes, not one

Read-only and required look adjacent but gate opposite directions, and they compose:

  • Read-only (readOnly*) gates hydration — the value is skipped if present.
  • Required (required*) gates validation — the value is demanded if the validation adapter executes it.

A readOnly()->nullable() field is serialized, never accepts a client write, and advertises that its value may be null. The two helpers never conflict because they act in different phases.

Enumeration

Every field can constrain its value to (or away from) a fixed set:

Method Adds
in(array $values) In — value must be one of $values.
notIn(array $values) NotIn — value must not be one of $values.
Str::make('status')->in(['draft', 'published', 'archived']),

Context scoping

onCreate() / onUpdate() re-stamp every constraint appended inside the closure with that request context, so you don't repeat the …OnCreate suffix on each call:

Str::make('slug')->onCreate(function (StrBuilder $field): void {
    $field->required()->maxLength(64);
});

Inside onUpdate(), an Str::make('slug')->required() becomes "required on update only" — the closure context wins over the helper's default.

Composition and cross-field rules

These appear on every field. They build up the constraint vocabulary that the optional validation adapter executes; core itself never runs them against data.

Method Effect
constrain(ConstraintInterface ...$c) Attaches constraints directly — the typed escape hatch for rules the helpers don't cover, your own implementations included. Each constraint carries its own context; constrain() does not re-stamp it.
sequentially(ConstraintInterface ...$c) Applies the constraints in order, stopping at the first failure; all must ultimately hold.
atLeastOneOf(ConstraintInterface ...$alt) Passes if the value satisfies at least one alternative.
when($condition, $builder) Applies the constraints appended inside $builder only when $condition returns true for the value. The wrapped rules fold into a single When; the condition is opaque PHP, so it does not round-trip to JSON Schema.
compareWith(string $field, Comparison $op) Cross-field comparison: the operator reads <this field> <op> <$field>. $op is a Comparison enum case.

UserResource's passwordConfirm stacks all three of the composition forms in one chain. Unlike the stored-but-write-only password field above, passwordConfirm is computed() because it is a transient confirmation value that is only compared, never persisted. atLeastOneOf()/sequentially()/when() take constraint value objects directly (from the constraint vocabulary), the same way each(new MinLength(1)) does in field types:

use haddowg\JsonApi\Resource\Constraint\Comparison;
use haddowg\JsonApi\Resource\Constraint\MinLength;
use haddowg\JsonApi\Resource\Constraint\Pattern;

Str::make('passwordConfirm')
    ->computed()
    ->serializeUsing(fn(): ?string => null)
    ->atLeastOneOf(
        new MinLength(8),
        new Pattern('^.*[0-9].*$'),
    )
    ->when(
        static fn(mixed $value): bool => $value !== null && $value !== '',
        static function (StrBuilder $field): void {
            $field->minLength(8);
        },
    )
    ->compareWith('password', Comparison::EqualTo),

compareWith is directional. The album pair reads availableUntil > availableFrom — this field is the left operand:

Date::make('availableUntil')
    ->nullable()
    ->compareWith('availableFrom', Comparison::GreaterThan),

See validation for the full constraint vocabulary and the create/update context model, and field types for the per-type helpers (minLength, min, before, …) that wrap these same constraints.

The four hooks

When the accessor and per-type casting aren't enough for a single member, replace part of its read or write path with a closure. Two hooks customise reading, two customise writing — reach for them before dropping to a whole-resource custom serializer / hydrator.

Hook Closure signature Replaces
serializeUsing($fn) fn(mixed $model, JsonApiRequestInterface $request, string $name): mixed The full read path (accessor + cast).
extractUsing($fn) fn(mixed $model, JsonApiRequestInterface $request, string $name): mixed The raw read; the field's per-type cast still applies.
deserializeUsing($fn) fn(mixed $value, array $data): mixed The per-type cast on hydration.
fillUsing($fn) fn(mixed $model, mixed $value, array $data, string $name): mixed The full write path; return the model (or null to keep it unchanged).

On read, serializeUsing wins over extractUsing; on write, fillUsing wins over deserializeUsing. The split matters: extractUsing lets per-type casting finish the job, while serializeUsing owns the value outright (it is the place to null a computed transient on read, as passwordConfirm above does). To suppress a stored member from output entirely, prefer writeOnly() — it drops the member rather than rendering it as null.

Worked example: a computed read and a renamed column

TrackResource is the most instructive declaration. displayTitle is computed() (no column) and readOnly(), with its value assembled across two real columns purely on read via extractUsing(); durationSeconds is a plain field whose column is renamed with storedAs(), so the rename round-trips transparently through serialize and hydrate without a hook at all:

final class TrackResource extends AbstractResource
{
    public static string $type = 'tracks';

    public function fields(): array
    {
        return [
            Id::make(),
            Str::make('title')->required()->sortable(),
            Integer::make('trackNumber')->min(1)->sortable(),
            Integer::make('durationSeconds')->storedAs('length_seconds'),
            // …
            Str::make('displayTitle')
                ->computed()
                ->readOnly()
                ->extractUsing(static fn(mixed $track): string => $track instanceof Track
                    ? \sprintf('%d. %s', $track->trackNumber, $track->title)
                    : ''),
            // …
        ];
    }
}

The single-field hooks are the right tool for one member's quirk. When the whole read or write path needs hand-writing — a request-dependent attribute set, say — drop to a custom serializer or hydrator instead. The example's TrackSerializer is registered as a read override for exactly that reason, while the resource above still hydrates writes.

Every on() attribute's backing relation chain is eager-loaded on every read — a load-not-render hint, distinct from ?include (which renders into included) — so the final related model is materialised before the flattened value is read. The dedup set of every on() chain (in field order) is exposed via the DeclaresEagerLoadsInterface::eagerLoadRelationshipPaths() capability (which AbstractResource implements):

Str::make('authorName')->on('author')->storedAs('name'),
Str::make('countryName')->on('publisher.country')->storedAs('name'),
// eagerLoadRelationshipPaths() => ['author', 'publisher.country']

A multi-hop chain ('publisher.country') is walked segment by segment, batch-loaded across the targets the previous level loaded (the same level-walk ?include uses) — O(depth) queries, never per-row. Because every segment is to-one, eager-loading never flips a relationship's linkage rendering, so the document is unchanged. The core library only declares the eager set; the host data layer executes the loading and excludes it from included — an eager load changes the query plan, never the document. Because the set is author-declared, a host may bypass the client-include safeguards (depth cap, allowed-paths, cannotBeIncluded) for it.

The eager set is validated at boot (fail loud)

The eager set is checked at container warm-up — so a mistake fails at cache:clear / deploy, not as a runtime 500. EagerLoadValidator walks every segment of every on() chain across types and throws a \LogicException when:

  • a segment names no declared relation (a typo — it would otherwise silently no-op); or
  • a segment is a to-many relation. on() flattens a single scalar from a to-one chain, so a to-many at any depth — including an ancestor in a dot-path, not just the leaf — is not flattenable. Use ?include to materialise a collection instead.

A segment may be hidden() (the internal-association idiom) or visible — both pass, because the chain is to-one. A polymorphic (MorphTo) or inventory-less segment whose next type cannot be resolved to a single relation-declaring serializer is left unwalked (skipped), not thrown — matching the include walk.

Relationships are fields too

A relationship is declared in the same fields() list and produces the resource object's relationships member. Relations inherit this shared surface (presence, read-only scoping, context) and add their own linkage-shaped helpers. The example's BelongsTo::make('album', 'albums') and HasMany::make('tracks', 'tracks') sit alongside the attribute fields above. See relations for the relation field types and how the server registry resolves the related resource.

Next

  • Field types — the per-type delta for each concrete field (Str, Integer, DateTime, Map, ArrayList, …).
  • Relations — relationship field types (BelongsTo, HasMany, MorphTo, …) and the registry.
  • Resources — how fields() drives the serialize and hydrate walks, id generation, and registration.
  • Validation — the constraint vocabulary and the create/update context model the presence and composition helpers feed.
  • OpenAPI generation — how a field projects to a schema, and the describedAs() / example() authoring surface that shapes it.