ADR-0009: Unified ResourceData Pattern
Accepted Cross-ProjectDate: 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
ResourceDatabase class — an immutable, readonly DTO with constructor-promoted properties, reflection-basedtoArray(), automatic value transformation, and runtime relation validation. - Kendo uses Laravel's native
JsonResourcewith manualtoArray()field mapping and@propertydocblocks 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
| Aspect | JsonResource (Kendo) | ResourceData (Brick Inventory) |
|---|---|---|
| Type safety | @property docblocks (hints only) | Constructor-promoted readonly properties (enforced) |
| PHPStan compliance | Requires docblock maintenance | Native type checking |
| Relation safety | EAGER_LOAD constant (advisory) | Runtime validation via MissingRelationException |
| Serialization | Manual field-by-field in toArray() | Automatic via reflection + transformValue() |
| Request awareness | Some Resources filter by auth context | Resources 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
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:
| Method | Description |
|---|---|
Constructor-promoted readonly properties | Resource shape is its constructor signature — no manual toArray() |
from(Model): static | Factory method. Validates the required relations/aggregates are loaded (loud — never loads them itself, amended 2026-07-03), constructs the Resource |
toArray(): array | Reflection-based serialization via get_object_vars($this) + transformValue() |
transformValue(mixed): mixed | Automatic conversion of nested ResourceData, BackedEnum, DateTimeInterface, arrays (recursive) |
collection(Collection): array | Maps each model through from(). No self-heal (amended 2026-07-03) — callers supply a fully-loaded collection |
EAGER_LOAD constant | Public array — single source of truth for relation loading |
EAGER_LOAD_COUNT / EAGER_LOAD_SUM constants | Aggregate spec (amended 2026-07-03): relation list for withCount, relation => column map for withSum |
requiredRelations(): array | Returns static::EAGER_LOAD. Derived, not independently declared |
validateRelationsLoaded(Model): void | Throws 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()callsvalidateRelationsLoaded()and never loads anything.loadMissing(/->load(/loadCount(/loadSum(are forbidden insideResourceDatasubclasses and the base. A missing relation or aggregate column throwsMissingRelationExceptionnaming 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, orHydrator::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:
| Verb | Input | Use |
|---|---|---|
hydrate(Model) | one fetched model | post-mutation single-model paths (e.g. before a broadcast or a store/update response) |
hydrateMany(Collection) | fetched collection | bulk paths — Eloquent batches each load across the set (one query per relation, not per model) |
applyTo(Builder) | query builder | read 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:
- Forced loads run before
loadMissing. A forcedload()re-queries its relation wholesale (destructive);loadMissingis additive. The reverse order discards whatloadMissingfilled. - 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:
- Supply first. Land hydrators / call-site eager-loads for every
from()/collection()consumer, with the gate still self-healing. - Flip last. Remove the self-heal from
from()implementations and the basecollection()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
| Level | Mechanism |
|---|---|
| 1 | Arch test rejecting loadMissing( / ->load( / loadCount( / loadSum( tokens inside app/Http/Resources/** (kendo KD-0907 shape) — added in the same PR as the flip |
| 1 | Hydrator 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 |
| 2 | deptrac 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:
UserResourceData // Full representation including email
UserPublicResourceData // Same shape minus sensitive fieldsThe 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
- Copy the
ResourceDatabase class andMissingRelationExceptionfrom Brick Inventory - Convert the 4 array-backed resources to standalone
final readonlyDTOs - Update architecture tests for the new pattern
Phase 2: Model-Backed Resources (18 of 19)
- Convert 18 model-backed resources to
ResourceData(all exceptUserResource) - Rename with
ResourceDatasuffix - Update controllers to use
ResourceData::from()andResourceData::collection() - Update tests
Phase 3: UserResource Split
- Split
UserResourceintoUserResourceData(full, with email) andUserPublicResourceData(no email) - Update controllers to select the appropriate resource based on authorization context
- Update tests
Options Considered
| Option | Verdict | Reason |
|---|---|---|
Keep Laravel's JsonResource | Rejected | Stringly-typed, no runtime relation enforcement, manual serialization, PHPStan coverage relies on docblock discipline. |
Adopt ResourceData as cross-project standard | Accepted | Constructor 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
| Option | Verdict | Reason |
|---|---|---|
| Keep belt-and-suspenders (self-heal + validate) | Rejected | Empirically 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 anywhere | Rejected as fleet mandate | Sufficient 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 paths | Accepted | Enforcement 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 now | Deferred | Couples 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
ResourceDatamust 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
| Territory | Single-model gate | collection() | Next step |
|---|---|---|---|
| kendo | LIVE 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 |
| codebook | mostly live (13 validate; 2 self-heal stragglers) | self-heals | In review — WR-0313 executed 2026-07-03: supply PR #453 + stacked flip PR #454 (9 gaps closed, ResourceLoudGateTest added) |
| entreezuil | LIVE (3/3 validate, no self-heal) | self-heals | Flip collection() + arch test; no Hydrators needed at current scale |
| ublgenie | dead (3/3 self-heal-then-validate) | self-heals | Supply-first migration, then flip |
| brick-inventory | dead (6 self-heal; 4 validate) | self-heals | Supply-first migration, then flip; dual-base carve-out unchanged (ComputedResourceData has no Eloquent supply side, out of scope by construction) |
| town-crier | live on ReviewRequestResourceData; ReviewRequestSummaryData documents reliance on collection() self-heal | self-heals (load-bearing by design) | Migrate the documented reliance at the call site, then flip; shared-repo courtesy review applies |
| emmie | dead (11/11) | self-heals | Folds 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
| Territory | State | Notes |
|---|---|---|
| kendo | Complete | All resources migrated (PRs #442, #445, #446 — merged 2026-03-11). |
| brick-inventory | Complete | Pattern 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. |
| ublgenie | Complete | PR #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. |
| emmie | Partial → 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. |
| entreezuil | Complete | PR #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. |