Beyond toArray: Making API Resources Do Real Work
Laravel's JsonResource class is deceptively simple. Most teams use it as a glorified array cast, but it carries enough power to handle sparse fieldsets, conditional relationship loading, and multi-version response contracts — without reaching for a dedicated API layer package.
Sparse Fieldsets Without a Package
JSON:API specifies that clients can request only the fields they need via ?fields[articles]=title,body. Implementing this in a resource is straightforward once you centralise the parsing.
// app/Http/Resources/Concerns/SparseFieldset.php
trait SparseFieldset
{
protected function allowed(string $type, array $fields): array
{
$requested = request()
->input("fields.{$type}");
if (! $requested) {
return $fields;
}
$keys = array_flip(explode(',', $requested));
return array_intersect_key($fields, $keys);
}
}
// app/Http/Resources/ArticleResource.php
class ArticleResource extends JsonResource
{
use SparseFieldset;
public function toArray(Request $request): array
{
return $this->allowed('articles', [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'created_at' => $this->created_at,
]);
}
}
The allowed() helper filters the field map to only what the client requested. No extra dependencies, no magic.
Conditional Relationship Loading
The built-in whenLoaded() is good, but it only checks whether the relation is already eager-loaded. For APIs that accept an ?include= parameter, you want to drive eager loading from the resource itself rather than the controller.
// app/Http/Resources/ArticleResource.php
public function with(Request $request): array
{
return [];
}
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => $this->whenLoaded('author', fn () =>
new UserResource($this->author)
),
'tags' => $this->whenLoaded('tags', fn () =>
TagResource::collection($this->tags)
),
];
}
In the controller, parse ?include= once and eager-load accordingly:
// app/Http/Controllers/ArticleController.php
public function show(Article $article): ArticleResource
{
$includes = array_intersect(
explode(',', request()->input('include', '')),
['author', 'tags'] // allowlist
);
return new ArticleResource(
$article->loadMissing($includes)
);
}
This keeps the allowlist in the controller (where auth context lives) and the shape in the resource.
Clean API Versioning Without Route Duplication
The temptation is to create V1\ArticleResource and V2\ArticleResource as entirely separate files. That leads to drift. A better pattern: a single resource class that branches on a version header or route prefix.
// app/Http/Resources/ArticleResource.php
public function toArray(Request $request): array
{
$version = $request->header('X-API-Version', 'v1');
return match ($version) {
'v2' => $this->v2Fields(),
default => $this->v1Fields(),
};
}
private function v1Fields(): array
{
return [
'id' => $this->id,
'title' => $this->title,
];
}
private function v2Fields(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'metadata' => $this->metadata,
];
}
When v1 is retired, delete v1Fields() and the match arm. No orphaned route files, no duplicated middleware stacks.
For larger APIs where versions diverge significantly, namespace the resource classes but share a base:
// app/Http/Resources/V2/ArticleResource.php
class ArticleResource extends \App\Http\Resources\ArticleResource
{
public function toArray(Request $request): array
{
return array_merge(parent::toArray($request), [
'slug' => $this->slug,
]);
}
}
Inheritance is fine here — the relationship is genuinely "is-a".
Key Takeaways
- Sparse fieldsets can be implemented with a single trait and
array_intersect_key— no package needed. - Conditional includes belong in the controller (allowlist) and the resource (
whenLoaded); never auto-load in the resource. - Version branching inside one resource class is maintainable for minor divergence; namespace inheritance works for major breaks.
- Keep resources focused on shape, not business logic — computed values belong in model accessors or dedicated DTOs.
- Always test resources with
ArticleResource::make($model)->toArray(request())in Pest; it's fast and catches regressions early.