Skip to content

ADR-0009: Unified ResourceData Pattern

Proposed 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.
  • Issue Tracker 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 (Issue Tracker)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.

How It Works

php
final readonly class IssueResource 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
UserResource        // Full representation including email
UserPublicResource  // 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.

Migration Strategy for Issue Tracker

The 22 existing JsonResource classes will be migrated to ResourceData. This is mechanical refactoring:

  1. Copy the ResourceData base class from Brick Inventory
  2. Convert each Resource: constructor properties, from() factory, EAGER_LOAD constant
  3. Split UserResource into UserResource (full) and UserPublicResource (no email)
  4. Update controller return types
  5. 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 Issue Tracker — 22 Resources + controller updates + test updates
  • 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

Architecture documentation for contributors and collaborators.