All topics
Laravel

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

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

The API's shape is whatever the migrations happen to produce today, and it changes the moment a column is added or renamed.

Prefer

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'));
}

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

php
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

php
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

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,
];

The visibility rule lives wherever a developer remembered to copy it, and drifts the first time only two of three copies get updated.

Prefer

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,
            ),
        ];
    }
}

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.

Download
.claude/skills/laravel-api-resources/SKILL.md

Drop 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