ADR-0009: Unified ResourceData Pattern
Proposed Cross-ProjectDate: 2026-02-23
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. - Issue Tracker 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 (Issue Tracker) | 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.
How It Works
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:
| Method | Description |
|---|---|
Constructor-promoted readonly properties | Resource shape is its constructor signature — no manual toArray() |
from(Model): static | Factory method. Loads missing relations, validates them, 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 | Bulk-loads required relations once, maps each model through from() |
EAGER_LOAD constant | Public array — single source of truth for relation loading |
requiredRelations(): array | Returns static::EAGER_LOAD. Derived, not independently declared |
validateRelationsLoaded(Model): void | Throws 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_LOADconstant — serves as both the controller's loading hint (Model::with(Resource::EAGER_LOAD)) and the Resource's relation contractrequiredRelations()— derived fromEAGER_LOAD, powersloadMissing()andvalidateRelationsLoaded()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:
UserResource // Full representation including email
UserPublicResource // 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.
Migration Strategy for Issue Tracker
The 22 existing JsonResource classes will be migrated to ResourceData. This is mechanical refactoring:
- Copy the
ResourceDatabase class from Brick Inventory - Convert each Resource: constructor properties,
from()factory,EAGER_LOADconstant - Split
UserResourceintoUserResource(full) andUserPublicResource(no email) - Update controller return types
- 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. |
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
ResourceDatamust 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