There's a moment in almost every Laravel project where someone opens the Network tab and finds the surprise. The user endpoint is returning password_hash, remember_token, stripe_customer_id, internal_notes, and a created_at that the mobile team is parsing as a string. Nobody added these on purpose. They came along for the ride because somewhere a controller called return $user; and Eloquent dutifully serialized the whole row.

That's the gap API Resources are designed to fill. They sit between the model and the response and decide — explicitly, in one place — what your API actually exposes. The framework has had them since 5.5; they are still the cleanest seam in the whole stack, and they are still the most under-used.

This article is about using them the way they were meant to be used: as the single, boring place where your JSON shape lives.

The Problem With "Just Return The Model"

Eloquent's toArray() is convenient and it is also a security trap. It serializes every column on the model unless you opt out via $hidden. $hidden works, but it pushes a presentation concern down into the model — and now your User knows about HTTP. The day you need a different shape for the admin API and the public API, you start adding flags, traits, and conditional makeVisible() calls until the model becomes a switchboard.

A Resource flips the question. The model owns persistence. The Resource owns the shape of one specific response.

Bash
php artisan make:resource UserResource
PHP
// app/Http/Resources/UserResource.php
namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

final class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'avatar_url' => $this->avatar_url,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

That is the whole pattern. Anything not listed here will not be in the response — password_hash, stripe_customer_id, internal_notes, and the column someone adds next month for an experimental feature flag all stay on the server side. The Resource is your allow-list, and an allow-list is the only kind of list that survives a refactor.

Conditional Fields Without if Soup

The temptation, once you have a Resource, is to start branching. Admins see one shape, customers see another, the mobile app needs a stripped-down version. Done badly, this turns into a hundred-line toArray full of if ($request->user()->isAdmin()) checks.

Laravel ships four helpers that keep this clean: when, whenLoaded, mergeWhen, and whenNotNull. Each one returns a "missing value" sentinel that the Resource pipeline drops from the final array. You write them as if the field is always there; the framework decides whether to include it.

PHP
public function toArray($request): array
{
    return [
        'id'         => $this->id,
        'name'       => $this->name,
        'email'      => $this->email,

        // Only included when the relation was eager-loaded.
        'orders'     => OrderResource::collection($this->whenLoaded('orders')),

        // Only included when the field is non-null.
        'phone'      => $this->whenNotNull($this->phone),

        // Only included for admins, and as a flat field.
        'last_login_ip' => $this->when(
            $request->user()?->isAdmin(),
            fn () => $this->last_login_ip,
        ),

        // Merge a whole block of admin-only fields.
        $this->mergeWhen($request->user()?->isAdmin(), [
            'internal_notes'     => $this->internal_notes,
            'stripe_customer_id' => $this->stripe_customer_id,
        ]),
    ];
}

whenLoaded is the one I reach for the most. It quietly enforces the rule that relations must be eager-loaded — if a controller forgot to call ->with('orders'), the field is simply omitted instead of triggering an N+1 query while serializing.

Collections, Pagination, And The data Wrapper

A single Resource handles one model. For lists, you call UserResource::collection($users) and Laravel wraps each model. The result is automatically nested under a data key:

JSON
{
  "data": [
    { "id": 1, "name": "Ada" },
    { "id": 2, "name": "Linus" }
  ]
}

When you pass a paginator instead of a plain collection, Laravel adds two more top-level keys for free — links (first/last/prev/next) and meta (current page, per-page, totals). That format is the closest thing the Laravel ecosystem has to a default for paginated APIs, and most client SDKs expect it.

PHP
// app/Http/Controllers/Api/UserController.php
public function index(Request $request)
{
    return UserResource::collection(
        User::query()
            ->with('roles')
            ->paginate($request->integer('per_page', 25)),
    );
}

If you need a separate top-level shape for the collection itself — totals, summary fields, links to related resources — generate a dedicated collection class:

Bash
php artisan make:resource UserCollection
PHP
final class UserCollection extends ResourceCollection
{
    public function toArray($request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total_active' => $this->collection->where('active', true)->count(),
            ],
        ];
    }
}

Diagram showing a User Eloquent model on the left with internal columns (password_hash, stripe_customer_id, internal_notes), passing through a UserResource transformer that strips hidden fields and adds whenLoaded relations, producing a clean JSON envelope on the right with id, name, email, avatar_url, and a nested orders array. Below the flow, a paginator path shows the same Resource wrapping a paginated collection into data + links + meta.
The Resource is the only thing that decides what crosses the HTTP boundary.

Customizing The Envelope: wrap, additional, And withoutWrapping

The default data wrapper is fine for most APIs and confusing for some. If your client SDK expects the top-level array to be the list itself, call JsonResource::withoutWrapping() in a service provider — usually AppServiceProvider::boot():

PHP
public function boot(): void
{
    JsonResource::withoutWrapping();
}

If your SDK expects something else, like result, swap the key globally with JsonResource::wrap('result'), or per-resource by overriding the static $wrap property on a Resource class.

For a single-resource endpoint, additional() lets you attach top-level metadata without polluting toArray:

PHP
return UserResource::make($user)->additional([
    'meta' => [
        'fetched_at'   => now()->toIso8601String(),
        'api_version'  => 'v2',
    ],
]);

These are small things, and they're exactly the kind of small thing that gets hard to change later if your transformation logic is scattered across thirty controllers.

Versioning Resources Without A Migration Of Pain

The day you ship v2 of your API, you do not want to rewrite controllers or sprout if ($version === 'v2') blocks inside toArray. The trick is to namespace Resources by version and keep the controller dumb:

Text
app/Http/Resources/V1/UserResource.php
app/Http/Resources/V2/UserResource.php
app/Http/Controllers/Api/V1/UserController.php
app/Http/Controllers/Api/V2/UserController.php

Each version's controller imports its own Resource. The model never knows. Old clients keep their shape, new clients get the new one, and deprecation is a matter of deleting a folder once the analytics confirm nobody is on v1 anymore.

Whiteboard sketch on cream paper of the response pipeline — a controller eager-loading relations, calling UserResource::collection(), the resource wrapping the data into a JSON envelope with data, links, and meta keys. Below the pipeline, three side-by-side panels contrast local development, where returning the model "just works", with production where Eloquent quietly serializes every column, and finally with the feedback loop where a couple of assertJsonMissingPath / assertJsonPath tests pin the boundary so future columns stay behind the allow-list.
Resources turn the gap between local and production into a tested boundary.

The Mistakes That Show Up In Code Review

A few patterns I push back on every time:

  1. Returning the model directly when a Resource exists for it. "I'll just return $user for the dashboard endpoint" is how the mobile app starts depending on email_verified_at being present. Standardize: every JSON endpoint goes through a Resource.
  2. Loading relations inside toArray. If you call $this->orders without whenLoaded, you've silently re-introduced N+1. Make eager loading the responsibility of the controller, and use whenLoaded so the Resource fails closed.
  3. Letting Resources call services. The Resource is a transformer. It should not hit the cache, send emails, or compute anything that needs more than the model in front of it. If a field requires real work, put that work in the Action that produced the model and pass the result in.
  4. Date strings without a format. $this->created_at will serialize as whatever Carbon's default does, which differs across PHP versions. Pick ISO 8601 and stick to it: $this->created_at?->toIso8601String().

Testing The Shape, Not The Framework

You do not need to test that JsonResource::collection returns an array. What you do need to test is that your shape contract holds — that admin fields stay invisible to non-admins, that hidden columns never appear, and that whenLoaded actually requires the relation to be eager-loaded.

PHP
// tests/Feature/UserResourceTest.php
it('hides internal columns from regular users', function () {
    $user = User::factory()->create([
        'stripe_customer_id' => 'cus_test_123',
        'internal_notes'     => 'flagged',
    ]);

    actingAs(User::factory()->create())
        ->getJson(route('api.users.show', $user))
        ->assertOk()
        ->assertJsonMissingPath('data.stripe_customer_id')
        ->assertJsonMissingPath('data.internal_notes');
});

it('exposes admin-only fields to admins', function () {
    $admin = User::factory()->admin()->create();
    $user  = User::factory()->create(['stripe_customer_id' => 'cus_test_123']);

    actingAs($admin)
        ->getJson(route('api.users.show', $user))
        ->assertOk()
        ->assertJsonPath('data.stripe_customer_id', 'cus_test_123');
});

Two tests, the boundary is locked, and the next person who tries to expose internal_notes to everyone gets a red CI run instead of a Slack message from the security team.

A One-Sentence Mental Model

API Resources are the allow-list between your database row and your public payload — keep them thin, lean on whenLoaded, version them by namespace, and the day a column gets renamed or a relation gets sensitive, there's exactly one file you have to touch.