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.
whenLoadedomits 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.