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.
php artisan make:resource UserResource
// 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.
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:
{
"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.
// 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:
php artisan make:resource UserCollection
final class UserCollection extends ResourceCollection
{
public function toArray($request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_active' => $this->collection->where('active', true)->count(),
],
];
}
}
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():
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:
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:
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.
The Mistakes That Show Up In Code Review
A few patterns I push back on every time:
- Returning the model directly when a Resource exists for it. "I'll just return
$userfor the dashboard endpoint" is how the mobile app starts depending onemail_verified_atbeing present. Standardize: every JSON endpoint goes through a Resource. - Loading relations inside
toArray. If you call$this->orderswithoutwhenLoaded, you've silently re-introduced N+1. Make eager loading the responsibility of the controller, and usewhenLoadedso the Resource fails closed. - 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.
- Date strings without a format.
$this->created_atwill 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.
// 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.



