Beyond toArray: Getting Serious About Laravel API Resources
Most Laravel codebases start with a UserResource that returns every column and calls it done. That works until a mobile client complains about payload size, a second API version ships, or a relationship silently triggers 200 extra queries. This article fixes all three.
Sparse Fieldsets Without a Package
JSON:API specifies ?fields[users]=id,email as a way for clients to request only the columns they need. You can implement a lightweight version natively.
// app/Http/Resources/Concerns/SparseFieldset.php
trait SparseFieldset
{
protected function sparseFields(array $all): array
{
$type = $this->resourceType();
$requested = request()->query('fields', []);
if (empty($requested[$type])) {
return $all;
}
$allowed = array_flip(explode(',', $requested[$type]));
return array_intersect_key($all, $allowed);
}
abstract protected function resourceType(): string;
}
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
use SparseFieldset;
protected function resourceType(): string { return 'users'; }
public function toArray(Request $request): array
{
return $this->sparseFields([
'id' => $this->id,
'email' => $this->email,
'name' => $this->name,
'created_at' => $this->created_at,
]);
}
}
Clients now send GET /users?fields[users]=id,email and receive a trimmed payload. No package required, no reflection magic.
Conditional Relationships Without N+1
$this->whenLoaded() is the correct primitive, but it only prevents serialisation of an unloaded relation — it does not load it. The loading decision must happen in the controller.
// app/Http/Controllers/UserController.php
public function index(Request $request): AnonymousResourceCollection
{
$includes = array_intersect(
explode(',', $request->query('include', '')),
['posts', 'roles'] // allowlist
);
$users = User::query()
->when(in_array('posts', $includes), fn ($q) => $q->with('posts'))
->when(in_array('roles', $includes), fn ($q) => $q->with('roles'))
->paginate();
return UserResource::collection($users);
}
// Inside UserResource::toArray
'posts' => PostResource::collection($this->whenLoaded('posts')),
'roles' => RoleResource::collection($this->whenLoaded('roles')),
The controller owns the eager-loading decision; the resource owns the shape. The two concerns never bleed into each other.
Versioning Without Duplication
A common mistake is copying UserResource into V2/UserResource and diverging forever. Instead, extend and override only what changed.
// app/Http/Resources/V2/UserResource.php
namespace App\Http\Resources\V2;
use App\Http\Resources\UserResource as V1UserResource;
class UserResource extends V1UserResource
{
public function toArray(Request $request): array
{
return array_merge(parent::toArray($request), [
'display_name' => $this->profile?->display_name,
// 'email' removed in v2 for privacy
'email' => $this->when(false, $this->email),
]);
}
}
Route groups bind the correct resource class:
Route::prefix('v1')->group(function () {
Route::apiResource('users', V1\UserController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('users', V2\UserController::class);
});
Each versioned controller returns its own resource class. The V1 resource stays frozen; V2 inherits and overrides. When V3 arrives, it extends V2 the same way.
Wrapping Metadata Consistently
For non-paginated endpoints, additional() keeps envelope logic out of controllers:
return UserResource::collection($users)
->additional([
'meta' => ['version' => 'v2', 'generated_at' => now()->toIso8601String()],
]);
For paginated responses, ResourceCollection lets you override paginationInformation to rename or remove keys your clients don't expect.
Takeaways
- Implement sparse fieldsets with a simple trait and an allowlist — no package needed.
- Keep eager-loading decisions in the controller; use
whenLoadedin resources purely for conditional serialisation. - Version resources by extension, not by copy-paste — freeze V1, extend into V2.
- Use
additional()for consistent envelope metadata without polluting controller return statements. - Always maintain an allowlist for
includeparameters to prevent arbitrary relationship traversal.