6 min read
API Resource Design
API Resources are the seam between database shape and API shape — pagination by default, conditional fields in one place, and versioned breaking changes.
Principles
API Resources are the seam between your database shape and your API shape
A model's columns are an implementation detail; a Resource is the actual contract with consumers, and the two are allowed to diverge.
Version the contract, not just the URL
A breaking change to a Resource's shape needs a new version consumers can opt into. Silently changing `UserResource`'s fields under the same endpoint breaks every existing client at once.
Paginate by default, not as an optimization added later
An unpaginated `index` endpoint is a resource-exhaustion bug waiting for the table to grow. Paginate from the first version, even when the table is small today.
Conditional fields belong in the Resource, not scattered across controllers
`$this->when()` keeps the 'does this field show for this viewer' logic in one place instead of duplicated per endpoint that happens to return the same model.
Errors need a consistent shape across every endpoint
A client that has to branch on which endpoint it called to know how to parse an error response is a sign the API has no actual error contract.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Leaking the database shape directly
Avoid
// Controller returns the model directly.
public function show(Post $post)
{
return $post; // every column, every relation lazy-loaded on access,
// and any column added later ships automatically.
}The API's shape is whatever the migrations happen to produce today, and it changes the moment a column is added or renamed.
Prefer
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'excerpt' => $this->excerpt,
'author' => new AuthorResource($this->whenLoaded('author')),
];
}
}
public function show(Post $post)
{
return new PostResource($post->load('author'));
}The API contract is explicit and stable — a new database column doesn't change the response until someone deliberately adds it to the Resource.
Unpaginated index endpoint
Avoid
public function index()
{
return PostResource::collection(Post::all()); // all of them
}Works fine at 200 rows, times out or exhausts memory at 200,000 — and by then it's a production incident, not a code review comment.
Prefer
public function index(Request $request)
{
return PostResource::collection(
Post::query()->paginate($request->integer('per_page', 25)),
);
}Pagination is part of the contract from day one, so growth in data never becomes a breaking performance change.
Conditional fields duplicated per controller
Avoid
// Same "is this the owner" check re-implemented in three
// controllers that each happen to return a Post.
return [
'id' => $post->id,
'title' => $post->title,
'draft_notes' => $request->user()?->id === $post->author_id
? $post->draft_notes
: null,
];The visibility rule lives wherever a developer remembered to copy it, and drifts the first time only two of three copies get updated.
Prefer
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'draft_notes' => $this->when(
$request->user()?->id === $this->author_id,
$this->draft_notes,
),
];
}
}The visibility rule lives in exactly one place, and every endpoint returning a PostResource inherits it automatically.
Review checklist
What I look for when reviewing a pull request that touches this area.
- No controller returns a raw model or `Model::all()` result directly.
- Every list endpoint paginates, even ones that feel small today.
- Conditional/visibility logic for fields lives in the Resource via `when()`/`whenLoaded()`, not duplicated in controllers.
- Breaking changes to a Resource's shape ship as a new version, not a silent change to the existing one.
- Error responses share one consistent shape (status, message, error code) across every endpoint.
- Eager loading needed by a Resource is loaded by the controller before the Resource runs, not triggered lazily per-request.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/laravel-api-resources/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: laravel-api-resources
description: >-
Laravel API Resource design: decoupling API shape from database shape,
pagination by default, conditional fields with when()/whenLoaded(),
versioning breaking changes, and consistent error responses. Use when
building or reviewing API endpoints.
argument-hint: "[endpoint or resource]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# API Resource Design
Apply when adding or reviewing an API endpoint. Before returning a model from a controller, check whether a Resource already exists for it — if not, that's the first thing to add, not an optional polish step.
## Rules
1. **API Resources are the seam between your database shape and your API shape** — A model's columns are an implementation detail; a Resource is the actual contract with consumers, and the two are allowed to diverge.
2. **Version the contract, not just the URL** — A breaking change to a Resource's shape needs a new version consumers can opt into. Silently changing `UserResource`'s fields under the same endpoint breaks every existing client at once.
3. **Paginate by default, not as an optimization added later** — An unpaginated `index` endpoint is a resource-exhaustion bug waiting for the table to grow. Paginate from the first version, even when the table is small today.
4. **Conditional fields belong in the Resource, not scattered across controllers** — `$this->when()` keeps the 'does this field show for this viewer' logic in one place instead of duplicated per endpoint that happens to return the same model.
5. **Errors need a consistent shape across every endpoint** — A client that has to branch on which endpoint it called to know how to parse an error response is a sign the API has no actual error contract.
## Patterns
### Leaking the database shape directly
**Avoid** — The API's shape is whatever the migrations happen to produce today, and it changes the moment a column is added or renamed.
```php
// Controller returns the model directly.
public function show(Post $post)
{
return $post; // every column, every relation lazy-loaded on access,
// and any column added later ships automatically.
}
```
**Prefer** — The API contract is explicit and stable — a new database column doesn't change the response until someone deliberately adds it to the Resource.
```php
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'excerpt' => $this->excerpt,
'author' => new AuthorResource($this->whenLoaded('author')),
];
}
}
public function show(Post $post)
{
return new PostResource($post->load('author'));
}
```
### Unpaginated index endpoint
**Avoid** — Works fine at 200 rows, times out or exhausts memory at 200,000 — and by then it's a production incident, not a code review comment.
```php
public function index()
{
return PostResource::collection(Post::all()); // all of them
}
```
**Prefer** — Pagination is part of the contract from day one, so growth in data never becomes a breaking performance change.
```php
public function index(Request $request)
{
return PostResource::collection(
Post::query()->paginate($request->integer('per_page', 25)),
);
}
```
### Conditional fields duplicated per controller
**Avoid** — The visibility rule lives wherever a developer remembered to copy it, and drifts the first time only two of three copies get updated.
```php
// Same "is this the owner" check re-implemented in three
// controllers that each happen to return a Post.
return [
'id' => $post->id,
'title' => $post->title,
'draft_notes' => $request->user()?->id === $post->author_id
? $post->draft_notes
: null,
];
```
**Prefer** — The visibility rule lives in exactly one place, and every endpoint returning a PostResource inherits it automatically.
```php
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'draft_notes' => $this->when(
$request->user()?->id === $this->author_id,
$this->draft_notes,
),
];
}
}
```
## Review checklist
- [ ] No controller returns a raw model or `Model::all()` result directly.
- [ ] Every list endpoint paginates, even ones that feel small today.
- [ ] Conditional/visibility logic for fields lives in the Resource via `when()`/`whenLoaded()`, not duplicated in controllers.
- [ ] Breaking changes to a Resource's shape ship as a new version, not a silent change to the existing one.
- [ ] Error responses share one consistent shape (status, message, error code) across every endpoint.
- [ ] Eager loading needed by a Resource is loaded by the controller before the Resource runs, not triggered lazily per-request.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim