Laravel API Resources: Sparse Fieldsets, Conditional Relationships, and Versioning
#laravel #api #resources #json-api

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

3 min read Mohamed Said Mohamed Said

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 include values in the controller — never pass user input directly to with().
  • Pair resources with JsonResource::withoutWrapping() only at the outermost collection level to avoid breaking nested serialization.

Found this useful?

Frequently Asked Questions

2 questions
Q01 Does sparse fieldset filtering affect database queries?
Not automatically. The trait filters the PHP array after the model is hydrated. To push field selection to the database you would need to parse the fields parameter in the controller and pass a select() clause — useful for wide tables but adds coupling between HTTP and query layers.
Q02 When should I move to separate versioned resource classes instead of branching inside one class?
Once a version introduces structural changes — renamed keys, removed relationships, or different pagination envelopes — a shared class becomes hard to reason about. A practical rule: branch inside one class for additive changes (new fields), split into separate classes when you need to remove or rename existing keys.

Continue reading

More Articles

View all