Laravel API Resources: Sparse Fieldsets, Conditional Relationships, and Stable Versioning
#laravel #api #resources #versioning

Laravel API Resources: Sparse Fieldsets, Conditional Relationships, and Stable Versioning

3 min read Mohamed Said Mohamed Said

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 whenLoaded in 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 include parameters to prevent arbitrary relationship traversal.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does `whenLoaded()` prevent the N+1 query problem on its own?
`whenLoaded()` only skips serialising a relation that was not loaded — it does not trigger or suppress a query. You must eager-load the relation in the controller. If you skip eager loading, Eloquent will still lazy-load the relation when the resource accesses it, producing N+1 queries.
Q02 Is extending a V1 resource for V2 safe when V1 must stay stable?
Yes, as long as V1 controllers continue to return the V1 resource class. The V2 resource extends V1 but is only instantiated by V2 controllers. Changes to V2 never affect V1 responses because PHP's inheritance is one-directional.
Q03 Should sparse fieldset filtering happen in the resource or in the query?
For small payloads, filtering in the resource (after the query) is fine and keeps the database layer clean. For very wide tables or high-traffic endpoints, push the field list into a `select()` clause on the query builder to reduce data transfer from the database. Both approaches can coexist: select a safe subset in the query, then apply sparse fieldset filtering in the resource.

Continue reading

More Articles

View all