Laravel API Resources: Sparse Fieldsets, Conditional Relationships, and Stable Contracts
#laravel #api #eloquent #rest

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

3 min read Mohamed Said Mohamed Said

Beyond toArray: Treating Resources as API Contracts

Most Laravel codebases use JsonResource as a glorified toArray call. That works until a mobile client starts requesting only three fields, a second API version ships, or a relationship accidentally exposes internal pricing data. Resources are your last line of defence before JSON hits the wire — treat them accordingly.


Sparse Fieldsets Without a Package

JSON:API specifies ?fields[articles]=title,body to limit response payload. You can implement a lightweight version natively.

// app/Http/Resources/Concerns/SparseFieldset.php
trait SparseFieldset
{
    protected function sparse(array $fields): array
    {
        $requested = request()->query('fields');

        if (blank($requested)) {
            return $fields;
        }

        $allowed = array_flip(
            array_map('trim', explode(',', $requested))
        );

        return array_intersect_key($fields, $allowed);
    }
}
// app/Http/Resources/ArticleResource.php
class ArticleResource extends JsonResource
{
    use SparseFieldset;

    public function toArray(Request $request): array
    {
        return $this->sparse([
            'id'         => $this->id,
            'title'      => $this->title,
            'body'       => $this->body,
            'created_at' => $this->created_at->toIso8601String(),
        ]);
    }
}

A request to GET /articles/1?fields=id,title returns only those two keys. No extra package, no middleware — just a trait.

Security note: never expose fields that are not explicitly listed in the resource. The whitelist is the contract; the query string is only a filter on top of it.


Conditional Relationships Without N+1

whenLoaded is well-known, but the pattern breaks down when you forget to eager-load in the controller. Pair it with a resource collection that enforces the load:

// app/Http/Resources/ArticleCollection.php
class ArticleCollection extends ResourceCollection
{
    public static $wrap = 'data';

    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->resource->total(),
                'per_page' => $this->resource->perPage(),
            ],
        ];
    }
}
// ArticleController
public function index(): ArticleCollection
{
    $articles = Article::query()
        ->with(['author', 'tags'])
        ->paginate(25);

    return new ArticleCollection($articles);
}

Inside ArticleResource:

public function toArray(Request $request): array
{
    return $this->sparse([
        'id'     => $this->id,
        'title'  => $this->title,
        'author' => new AuthorResource($this->whenLoaded('author')),
        'tags'   => TagResource::collection($this->whenLoaded('tags')),
    ]);
}

whenLoaded returns MissingValue when the relation is absent, which Eloquent silently omits from the JSON. The relationship key disappears entirely rather than serialising as null — a meaningful distinction for API consumers.


Versioning Resources Without Duplication

Avoid copying entire resource classes per version. Extend and override only what changed:

// app/Http/Resources/V2/ArticleResource.php
namespace App\Http\Resources\V2;

use App\Http\Resources\ArticleResource as V1ArticleResource;

class ArticleResource extends V1ArticleResource
{
    public function toArray(Request $request): array
    {
        return array_merge(parent::toArray($request), [
            'slug'    => $this->slug,        // new in v2
            'excerpt' => $this->excerpt,     // new in v2
            'body'    => $this->when(
                $request->boolean('include_body'),
                $this->body
            ),
        ]);
    }
}

Route groups resolve the correct namespace:

Route::prefix('v2')->namespace('App\Http\Controllers\V2')->group(
    base_path('routes/api_v2.php')
);

Enforcing the Contract in Tests

Use Pest's assertJson structure assertions to lock the shape:

it('returns stable article structure', function () {
    $article = Article::factory()->for(User::factory(), 'author')->create();

    $this->getJson("/api/v1/articles/{$article->id}")
        ->assertOk()
        ->assertJsonStructure([
            'data' => ['id', 'title', 'body', 'created_at'],
        ])
        ->assertJsonMissingPath('data.author'); // not loaded, must be absent
});

This catches accidental field additions or relationship leaks before they reach production.


Takeaways

  • Implement sparse fieldsets with a simple trait — no JSON:API package required.
  • whenLoaded omits keys entirely when relations are absent; use that intentionally.
  • Version resources by extension, not duplication — override only what changed.
  • Write structure-assertion tests to lock your API contract and catch regressions early.
  • Resources are a security boundary: whitelist fields explicitly, never pass $this->resource->toArray() blindly.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does `whenLoaded` return `null` or omit the key when the relation is not loaded?
It returns a `MissingValue` instance, which Laravel's JSON serialisation silently drops. The key is omitted from the response entirely, not serialised as `null`. This is intentional and useful for distinguishing 'not requested' from 'explicitly null'.
Q02 How do I prevent sparse fieldsets from exposing sensitive fields a client should never see?
The `sparse()` trait only filters down from the whitelist you define in `toArray`. A client can request fewer fields but never more than what the resource explicitly declares. Sensitive fields simply should not appear in the resource's field map.
Q03 Is extending a V1 resource for V2 safe when V1 changes?
It depends on your change policy. If V1 is frozen (common after a stable release), extension is safe. If V1 is still evolving, consider an abstract base resource that both versions extend, keeping shared logic in one place without coupling the versions directly.

Continue reading

More Articles

View all