Skip to content

ADR-0009: Unified ResourceData Pattern

Accepted Cross-Project

Date: 2026-02-23

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. Loads missing relations, validates them, 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): arrayBulk-loads required relations once, maps each model through from()
EAGER_LOAD constantPublic array — single source of truth for relation loading
requiredRelations(): arrayReturns static::EAGER_LOAD. Derived, not independently declared
validateRelationsLoaded(Model): voidThrows MissingRelationException if a required relation isn't loaded
toResponse() / toResponseWithStatus(int)Returns JsonResponse without envelope wrapping

Eager Loading: Belt and Suspenders

Resources declare a single source of truth:

  • EAGER_LOAD constant — serves as both the controller's loading hint (Model::with(Resource::EAGER_LOAD)) and the Resource's relation contract
  • requiredRelations() — derived from EAGER_LOAD, powers loadMissing() and validateRelationsLoaded() as a runtime safety net

One declaration, two purposes. No drift possible.

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.

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)

Risks

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

Implementation

TerritoryStateNotes
kendoCompleteAll resources migrated (PRs #442, #445, #446 — merged 2026-03-11).
brick-inventoryCompletePattern was already established before ADR was formalized.
ublgenieIn ProgressPR #18: 8 ResourceData classes (7 model-backed, 1 array-backed). Base class ported from kendo. Uses @param docblock pattern (BIO style) over assert().

Architecture documentation for contributors and collaborators.