Skip to content

ADR-0009: Unified ResourceData Pattern

Accepted Cross-Project

Date: 2026-02-23 (amended 2026-07-03)

Amendment 2026-07-03 — Loud-Failure Hydration & the Hydrator Supply Side

The original ADR blessed loadMissing() inside from()/collection() as a "belt and suspenders" runtime safety net. In practice the belt strangled the suspenders: self-healing immediately before validateRelationsLoaded() fills exactly what the gate would catch, so the gate was structurally dead in most adopters and silent N+1 — the failure mode this ADR promised to catch — went undetected. Kendo PR #1474 (KD-0647) fixed the root cause: self-heal removed, gate live, and a new Hydrator class family owning the supply side. This amendment canonicalizes that shape fleet-wide. The §Eager Loading section was rewritten (was "Belt and Suspenders"); §Loud-Failure Hydration and the Hydrator contract are new; the Base Class Contract rows for from() and collection() changed. Deliberation record: campaigns/cross-territory/2026-07-03-adr-0009-loud-hydration-amendment.md.

Context

The two active projects use divergent patterns for API Resource serialization:

  • Brick Inventory uses a custom ResourceData base class — an immutable, readonly DTO with constructor-promoted properties, reflection-based toArray(), automatic value transformation, and runtime relation validation.
  • Kendo uses Laravel's native JsonResource with manual toArray() field mapping and @property docblocks for type hints.

Both share the same philosophy — flat responses, IDs over nesting, no data envelope, explicit eager loading — but differ mechanically. The divergence creates cognitive overhead when moving between projects.

Key Differences

AspectJsonResource (Kendo)ResourceData (Brick Inventory)
Type safety@property docblocks (hints only)Constructor-promoted readonly properties (enforced)
PHPStan complianceRequires docblock maintenanceNative type checking
Relation safetyEAGER_LOAD constant (advisory)Runtime validation via MissingRelationException
SerializationManual field-by-field in toArray()Automatic via reflection + transformValue()
Request awarenessSome Resources filter by auth contextResources are dumb serializers — no request/auth awareness

Decision

ResourceData as the Standard

The custom ResourceData base class replaces Laravel's JsonResource across all projects.

Naming Convention

All model-backed resources use the ResourceData suffix: IssueResourceData, ProjectResourceData, UserResourceData, etc. This distinguishes them from Laravel's JsonResource and signals the DTO nature of the class.

How It Works

php
final readonly class IssueResourceData extends ResourceData
{
    public const EAGER_LOAD = ['project', 'assignee', 'labels'];

    public function __construct(
        public int $id,
        public string $title,
        public string $description,
        public int $project_id,
        public ?int $assignee_id,
        public PriorityEnum $priority,
        public Carbon $created_at,
    ) {}

    public static function from(Issue $issue): static
    {
        return new static(
            id: $issue->id,
            title: $issue->title,
            description: $issue->description,
            project_id: $issue->project_id,
            assignee_id: $issue->assignee_id,
            priority: $issue->priority,
            created_at: $issue->created_at,
        );
    }
}

Base Class Contract

The abstract ResourceData class provides:

MethodDescription
Constructor-promoted readonly propertiesResource shape is its constructor signature — no manual toArray()
from(Model): staticFactory method. Validates the required relations/aggregates are loaded (loud — never loads them itself, amended 2026-07-03), constructs the Resource
toArray(): arrayReflection-based serialization via get_object_vars($this) + transformValue()
transformValue(mixed): mixedAutomatic conversion of nested ResourceData, BackedEnum, DateTimeInterface, arrays (recursive)
collection(Collection): arrayMaps each model through from(). No self-heal (amended 2026-07-03) — callers supply a fully-loaded collection
EAGER_LOAD constantPublic array — single source of truth for relation loading
EAGER_LOAD_COUNT / EAGER_LOAD_SUM constantsAggregate spec (amended 2026-07-03): relation list for withCount, relation => column map for withSum
requiredRelations(): arrayReturns static::EAGER_LOAD. Derived, not independently declared
validateRelationsLoaded(Model): voidThrows MissingRelationException if a required relation or aggregate column isn't loaded
toResponse() / toResponseWithStatus(int)Returns JsonResponse without envelope wrapping

Eager Loading: Loud Gate, Explicit Supply (rewritten by the 2026-07-03 amendment)

Resources declare a single source of truth — the EAGER_LOAD / EAGER_LOAD_COUNT / EAGER_LOAD_SUM constants are simultaneously the supply-side loading spec and the Resource's runtime contract. One declaration, two purposes. No drift possible.

What changed: the original text made loadMissing() a "runtime safety net" inside from() and collection(). That self-heal ran immediately before validateRelationsLoaded(), filling exactly what the gate would catch — the gate was decorative, and a forgotten eager-load degraded into a silent N+1 instead of a caught defect. The amended contract splits the two roles cleanly:

  • The Resource is the demand side and the gate. from() calls validateRelationsLoaded() and never loads anything. loadMissing( / ->load( / loadCount( / loadSum( are forbidden inside ResourceData subclasses and the base. A missing relation or aggregate column throws MissingRelationException naming the resource class and the missing keys.
  • The Hydrator (or the querying call site) is the supply side. Loading happens before construction — builder-side (with/withCount/withSum, or Hydrator::applyTo()) or model-side (Hydrator::hydrate()/hydrateMany()) — never inside the serializer.

Aggregate validation semantics

Aggregate presence is tested with array_key_exists against the model's attribute bag — never isset. A SUM over zero rows writes null (ANSI semantics); isset reads that as absent, which turns "validated" into "re-queried on every call" on the supply side and false-negatives on the gate side. array_key_exists distinguishes column never queried (throws) from column queried, empty relation (passes; the resource applies ?? 0 itself).

Loud-Failure Hydration & the Hydrator (added by the 2026-07-03 amendment)

The Hydrator contract

A Hydrator is the supply side of exactly one serialization profile: one final readonly class per ResourceData that needs one, bonded via a resource() method returning the profile's class-string. The abstract base owns the loading sequence behind three final verbs that vary only by input shape:

VerbInputUse
hydrate(Model)one fetched modelpost-mutation single-model paths (e.g. before a broadcast or a store/update response)
hydrateMany(Collection)fetched collectionbulk paths — Eloquent batches each load across the set (one query per relation, not per model)
applyTo(Builder)query builderread paths — the whole profile resolves at query execution, O(1) queries

Subclasses supply resource() and, rarely, forcedNestedLoads() — the nested-aggregate seam for loads the flat constants can't express (e.g. a withCount on a relation-of-a-relation). Two ordering rules are fixed inside the final base verbs so subclasses cannot break them:

  1. Forced loads run before loadMissing. A forced load() re-queries its relation wholesale (destructive); loadMissing is additive. The reverse order discards what loadMissing filled.
  2. Aggregates are presence-guarded with array_key_exists (see above) so re-hydrating an already-hydrated model — the dual broadcaster+controller path — costs zero aggregate queries.

Where deptrac is present, pin the layer: Hydrators → {Models, Resources} only; Actions, Broadcasting, and Controllers may depend on Hydrators, never the reverse.

The serializer hydrates its own profile. When a component other than a controller serializes a Resource (a broadcaster, an exporter), that component's public entrypoints hydrate internally — callers (Actions) never touch a hydrator. Kendo's IssueBroadcaster is the reference: updated() hydrates the full profile, bulkUpdated()/myIssuesChangedMany() batch-hydrate, and the fanout internals are pure dispatch. This keeps the hydration obligation in one place per consumer instead of scattered across every calling Action.

When a Hydrator is mandatory

The loud gate is mandatory everywhere; the Hydrator class is mandatory only where it earns its weight. A profile MUST get a Hydrator when any of these hold:

  • it declares aggregates (EAGER_LOAD_COUNT / EAGER_LOAD_SUM) or needs a forced nested load;
  • it has multiple consumers (e.g. HTTP response + broadcast payload of the same profile);
  • it serves bulk mutation paths that re-serialize collections post-write.

For single-consumer, relations-only profiles, controller-side Model::with(Resource::EAGER_LOAD) (or loadMissing at the call site before from()) remains acceptable supply — the gate still enforces it. Territories at entreezuil scale need zero Hydrator classes to be compliant.

Migration ordering (binding for adopters)

The flip converts silent N+1s into 500s on any forgotten path, so order is not optional:

  1. Supply first. Land hydrators / call-site eager-loads for every from()/collection() consumer, with the gate still self-healing.
  2. Flip last. Remove the self-heal from from() implementations and the base collection() in a dedicated change, merged only when the full feature suite is green under the flipped gate. On compliance territories (NEN 7510 / ISO 27001) the flip PR must not also introduce new supply — pure removal, so a revert is trivial.

Enforcement

LevelMechanism
1Arch test rejecting loadMissing( / ->load( / loadCount( / loadSum( tokens inside app/Http/Resources/** (kendo KD-0907 shape) — added in the same PR as the flip
1Hydrator structure test: every app/Hydrators/* subclass is final readonly, resource() returns a ResourceData class-string, and the bonded profile declares at least one EAGER_LOAD* constant
2deptrac Hydrators layer, where deptrac exists

The Hydrator base is copied per territory with a provenance header (kendo backend/app/Hydrators/Hydrator.php @ 4aad21d45 is canonical). Promotion to a shared Composer package (sister to phpstan-warroom-rules) is deliberately deferred until a base-class defect ships in two territories — the base couples to each territory's local ResourceData, and freezing a package API at n=1 is premature.

Resources Are Dumb Serializers

Resources must never access the request, authentication context, or authorization logic. They transform a model into a response shape — nothing more.

When different contexts need different response shapes (e.g., email visible to managers but not regular users), create separate Resource classes:

php
UserResourceData        // Full representation including email
UserPublicResourceData  // Same shape minus sensitive fields

The controller decides which Resource to use based on authorization context.

No ResourceCollection Classes

Collections are handled by the static collection() method returning a plain PHP array. No custom collection classes.

Array-Backed Resources Are Excluded

Resources that wrap raw arrays (not Eloquent models) do not extend ResourceData. The base class requires from(Model): static and provides relation loading/validation — none of which applies to array data.

Array-backed resources become standalone final readonly DTOs with constructor-promoted properties, implementing JsonSerializable and Responsable directly. They get the same type safety and explicit shape benefits without pretending to have a model.

Examples in Kendo: StoryResourceData, FeaturePlannerResourceData, GithubRepositoryResourceData, GithubBranchResourceData.

Migration Strategy for Kendo

The 23 existing JsonResource classes will be migrated in three phases:

Phase 1: Infrastructure

  1. Copy the ResourceData base class and MissingRelationException from Brick Inventory
  2. Convert the 4 array-backed resources to standalone final readonly DTOs
  3. Update architecture tests for the new pattern

Phase 2: Model-Backed Resources (18 of 19)

  1. Convert 18 model-backed resources to ResourceData (all except UserResource)
  2. Rename with ResourceData suffix
  3. Update controllers to use ResourceData::from() and ResourceData::collection()
  4. Update tests

Phase 3: UserResource Split

  1. Split UserResource into UserResourceData (full, with email) and UserPublicResourceData (no email)
  2. Update controllers to select the appropriate resource based on authorization context
  3. Update tests

Options Considered

OptionVerdictReason
Keep Laravel's JsonResourceRejectedStringly-typed, no runtime relation enforcement, manual serialization, PHPStan coverage relies on docblock discipline.
Adopt ResourceData as cross-project standardAcceptedConstructor properties make shape explicit and PHPStan-verifiable. Runtime relation validation catches N+1 at construction. Already proven in two codebases.

Options Considered — 2026-07-03 amendment

OptionVerdictReason
Keep belt-and-suspenders (self-heal + validate)RejectedEmpirically a dead gate: fleet survey 2026-07-03 found the self-heal masking validateRelationsLoaded in emmie (11/11), ublgenie (3/3), BIO (6/10), kendo pre-#1474. The safety net was the failure mode.
Loud gate only, no Hydrator anywhereRejected as fleet mandateSufficient for small relations-only territories, but leaves aggregate loading, forced-nested ordering, and the null-sum trap re-solved by hand at every multi-consumer call site — kendo's evidence is that this scatters.
Loud gate mandatory + Hydrator where profiles have aggregates / multiple consumers / bulk pathsAcceptedEnforcement burden lands everywhere; machinery only where it earns its weight. Proven end-to-end in kendo PR #1474.
Ship the Hydrator base as a shared Composer package nowDeferredCouples to each territory's local ResourceData base; n=1 proven implementation. Copy-with-provenance; promote on second divergence.

Consequences

Positive

  • PHPStan level max compliance — constructor properties are natively type-checked
  • Explicit shape — constructor signature is the API contract
  • Runtime relation safety — catches N+1 regressions at construction time
  • Cross-project consistency — same pattern everywhere
  • Dumb Resources are trivially unit-testable
  • Automatic value transformation eliminates repetitive formatting

Negative

  • Migration churn in Kendo — 23 Resources + controller updates + test updates (phased across 3 PRs)
  • Custom infrastructure — contributors unfamiliar with ResourceData must learn the pattern (Laravel docs won't help)
  • (2026-07-03) The loud gate converts a forgotten eager-load from a silent slow response into a runtime MissingRelationException — a 500 in production if a path escapes the suite. Accepted deliberately (correct-over-shipped); the migration ordering rule and per-territory sequencing are the mitigation.
  • (2026-07-03) Per-territory copies of the Hydrator base can drift; package promotion is the standing escape hatch.

Risks

  • Contributor resistance to non-standard pattern — mitigated by simplicity (read the constructor, understand the shape) and documentation

Implementation

Amendment adoption (loud gate + Hydrator), as of 2026-07-03

TerritorySingle-model gatecollection()Next step
kendoLIVE on Issue family (PR #1474); 14 factories still self-heal (KD-0906)flip In Progress (KD-0905); arch test queued (KD-0907)Ally-driven — war room reviews, then exports the finished shape incl. the KD-0907 arch test
codebookmostly live (13 validate; 2 self-heal stragglers)self-healsIn review — WR-0313 executed 2026-07-03: supply PR #453 + stacked flip PR #454 (9 gaps closed, ResourceLoudGateTest added)
entreezuilLIVE (3/3 validate, no self-heal)self-healsFlip collection() + arch test; no Hydrators needed at current scale
ublgeniedead (3/3 self-heal-then-validate)self-healsSupply-first migration, then flip
brick-inventorydead (6 self-heal; 4 validate)self-healsSupply-first migration, then flip; dual-base carve-out unchanged (ComputedResourceData has no Eloquent supply side, out of scope by construction)
town-crierlive on ReviewRequestResourceData; ReviewRequestSummaryData documents reliance on collection() self-healself-heals (load-bearing by design)Migrate the documented reliance at the call site, then flip; shared-repo courtesy review applies
emmiedead (11/11)self-healsFolds into the ruled ADR-0009 full-migration campaign (campaigns/emmie/2026-06-12-adr-0009-full-migration-plan.md); sequenced last — NEN 7510 500-risk asymmetry

Original pattern adoption

TerritoryStateNotes
kendoCompleteAll resources migrated (PRs #442, #445, #446 — merged 2026-03-11).
brick-inventoryCompletePattern was already established before ADR was formalized. Sanctioned extension: BIO operates a dual-base architecture per BIO's local ADR-0010 (sovereign numbering — not war-room ADR-0010 which governs the Squad System): App\Http\Resources\ResourceData for model-sourced responses (12 subclasses) and App\Http\Resources\ComputedResourceData<TSource> for Action Result-DTO-sourced responses (5 subclasses). ComputedResourceData is structurally unable to declare EAGER_LOAD_* aggregates (it sources from DTOs, not Eloquent models), so EnforceResourceDataValidatorOptInRule has no scope on it by construction. The dual base is BIO-specific and does not cascade to other territories.
ublgenieCompletePR #18 merged 2026-04-01: 8 ResourceData classes (7 model-backed, 1 array-backed). Base class ported from kendo. Uses @param docblock pattern (BIO style) over assert(). 4 of 8 subclasses override EAGER_LOAD with non-empty arrays + call validateRelationsLoaded() per ADR-0009 contract.
emmiePartial → migration ruled (a)Emmie ships App\Http\Resources\DTOResource — a stripped-down variant of ResourceData (constructor-promoted readonly props + reflection toArray(), the type-safety win) missing the relation-safety contract: no EAGER_LOAD, no requiredRelations(), no validateRelationsLoaded(), no MissingRelationException, no recursive transformValue(). Method names diverge (fromModel vs from, collect vs collection). Count corrected 2026-06-12 (Surveyor scope mission): the "178 subclasses" cited at 2026-05-08 was wrong — on-disk reality at development tip is 95 DTOResource subclasses + 25 still-raw JsonResource classes never migrated (emmie's own DTOResource migration was itself partial). Bucket profile of the 95: 70 pure-scalar / 14 relation-loading / 11 nested-composition (25 relation-touching). Live lazy-load risk is bounded — every collection endpoint already eager-loads by convention; the only lazy loads are single-row store/update response paths (1–3 q). So the relation-safety value is a latent CI regression-guard, not an active-bleed fix. Commander ruled (a) — full migration — 2026-06-12: adopt canonical ResourceData (port base + MissingRelationException + transformValue), rename all 95 to *ResourceData with from/collection, add EAGER_LOAD + validateRelationsLoaded() to the 25 relation-touching, fold in the 25 JsonResource stragglers, retire DTOResource. Phased multi-PR campaign (campaigns/emmie/2026-06-12-adr-0009-full-migration-plan.md). Field report: reports/emmie/field/2026-06-12-surveyor-dtoresource-relation-scope.md. Tracked in deferred.md under [adr] adr-0009-emmie-dtoresource-disposition.
entreezuilCompletePR #214 (2026-06-12): base ResourceData + MissingRelationException ported from ublgenie; 5 final readonly ResourceData classes, all model-backed (UserResourceData, UserGuestResourceData, BranchResourceData, KioskResourceData, KioskInfoResourceData). 3 of 5 declare non-empty EAGER_LOAD + call validateRelationsLoaded(). No-bootstrap arch test (tests/Arch/ResourceDataArchitectureTest.php, reflection-based, mirrors DataTransferObjectsTest.php). Folded a 3rd-cycle Liaison N+1 signal: /me + /Login now loadMissing(UserResourceData::EAGER_LOAD) before from(), eliminating the constant +3 lazy queries (behavioural test under Model::preventLazyLoading()). KioskUserController's {data, meta} envelope reconstructed by hand (ResourceData::collection() has no ->additional()). JsonResource::withoutWrapping() retained — bare-array list endpoints byte-preserved. Datetime fidelity verified: base format('c') ≡ old toIso8601String(). Replaces deferred.md [adr] adr-0009-entreezuil-adoption.

Architecture documentation for contributors and collaborators.