Beyond Basic toArray: Production-Grade API Resources
Most Laravel tutorials stop at $this->merge([...]). In a real SaaS API you need three things most examples skip: sparse fieldsets so clients fetch only what they need, conditional relationship loading that doesn't trigger N+1 queries, and a versioning strategy that doesn't mean copy-pasting resource classes.
Sparse Fieldsets
The JSON:API spec defines ?fields[users]=id,name,email. Implementing it cleanly inside a resource keeps the controller unaware of field filtering.
// app/Http/Resources/Concerns/SparseFieldset.php
trait SparseFieldset
{
protected function sparseFields(string $type, array $fields): array
{
$requested = request()
->input("fields.{$type}");
if (! $requested) {
return $fields;
}
$allowed = array_flip(explode(',', $requested));
return array_filter(
$fields,
fn ($key) => isset($allowed[$key]),
ARRAY_FILTER_USE_KEY
);
}
}
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
use SparseFieldset;
public function toArray(Request $request): array
{
return $this->sparseFields('users', [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->toIso8601String(),
]);
}
}
A request to GET /users?fields[users]=id,name now returns only those two keys. The trait is reusable across every resource type.
Conditional Relationships Without N+1
Laravel's whenLoaded is well-known, but the pattern breaks down when you want to include a relationship only when the client explicitly requests it via ?include=posts.
// app/Http/Resources/UserResource.php
public function toArray(Request $request): array
{
$includes = array_flip(
explode(',', $request->input('include', ''))
);
return $this->sparseFields('users', [
'id' => $this->id,
'name' => $this->name,
'posts' => $this->when(
isset($includes['posts']) && $this->relationLoaded('posts'),
fn () => PostResource::collection($this->posts)
),
]);
}
The controller is responsible for eager-loading based on the same include parameter:
// app/Http/Controllers/UserController.php
public function index(Request $request): AnonymousResourceCollection
{
$allowed = ['posts', 'team'];
$includes = array_intersect(
explode(',', $request->input('include', '')),
$allowed
);
$users = User::with($includes)
->paginate(25);
return UserResource::collection($users);
}
This pattern keeps the resource honest: it will never serialize a relationship that wasn't loaded, and the controller never leaks transformation logic.
Versioning Without Class Duplication
The naive approach is V1\UserResource and V2\UserResource. That quickly becomes a maintenance nightmare. A cleaner approach uses a version-aware base resource and a small resolver.
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
use SparseFieldset;
public function toArray(Request $request): array
{
$base = [
'id' => $this->id,
'name' => $this->name,
];
return match ($this->apiVersion($request)) {
2 => array_merge($base, $this->v2Fields()),
default => $base,
};
}
private function v2Fields(): array
{
return [
'email' => $this->email,
'created_at' => $this->created_at->toIso8601String(),
];
}
private function apiVersion(Request $request): int
{
// Accept: application/vnd.api+json; version=2
preg_match('/version=(\d+)/', $request->header('Accept', ''), $m);
return isset($m[1]) ? (int) $m[1] : 1;
}
}
Version detection lives in one place. Adding V3 means adding a v3Fields() method and a new match arm — not a new file tree.
Takeaways
- Sparse fieldsets belong in a reusable trait, not in controllers or query scopes.
whenLoaded+ explicit include parsing prevents accidental N+1 while keeping resources declarative.- Version branching inside a single resource class is maintainable up to ~3 versions; beyond that, extract a versioned transformer layer.
- Always whitelist
includevalues in the controller — never pass user input directly towith(). - Pair resources with
JsonResource::withoutWrapping()only at the outermost collection level to avoid breaking nested serialization.