Back to Blog
·13 min read

Laravel API Resources: Shape Your JSON Like a Pro

LaravelPHPAPI Design

Your Eloquent models know too much. They carry hidden attributes, internal flags, and relationships the frontend never needs. Returning models directly from controllers leaks implementation details and makes breaking API changes inevitable.

API Resources solve this by defining a transformation layer between your domain and JSON.

Basic resource transformation

php artisan make:resource UserResource
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

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

public function index()
{
    return UserResource::collection(User::paginate(20));
}

The frontend gets a stable contract. You can rename database columns, add accessors, or restructure models without breaking clients—as long as the resource output stays consistent.

Conditional attributes

Not every field belongs in every response. Use mergeWhen() and when() to include data conditionally:

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

        $this->mergeWhen($request->user()?->isAdmin(), [
            'internal_notes' => $this->internal_notes,
            'last_login_ip' => $this->last_login_ip,
        ]),

        'team' => TeamResource::make($this->whenLoaded('team')),
        'posts_count' => $this->whenCounted('posts'),
    ];
}

whenLoaded() prevents N+1 queries by only including relationships that were eager-loaded. This is critical—without it, accessing $this->team in the resource triggers a query per row.

Nested resources and collections

Keep nesting shallow. Deeply nested JSON is hard to consume and expensive to serialize:

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'status' => $this->status->value,
            'total' => $this->total_in_cents / 100,
            'customer' => new CustomerResource($this->whenLoaded('customer')),
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

In the controller, always eager-load what the resource needs:

public function show(Order $order)
{
    $order->load(['customer', 'items.product']);

    return new OrderResource($order);
}

Resource collections with metadata

For list endpoints, wrap collections to add pagination meta:

class OrderCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->total(),
                'per_page' => $this->perPage(),
                'current_page' => $this->currentPage(),
                'last_page' => $this->lastPage(),
            ],
        ];
    }
}

Or rely on Laravel’s built-in pagination wrapping—UserResource::collection($paginator) automatically includes links and meta when you pass a paginator instance.

Avoiding the $this->resource trap

Inside a resource, $this delegates to the underlying model. That means you can call model methods directly:

'display_name' => $this->fullName(),
'is_verified' => $this->hasVerifiedEmail(),

But avoid heavy computation here. Precompute expensive values in the query or a service layer:

// In controller or repository
$users = User::query()
    ->withCount('posts')
    ->addSelect([
        'activity_score' => ActivityScore::select('score')
            ->whereColumn('user_id', 'users.id')
            ->limit(1),
    ])
    ->paginate(20);

API versioning with resources

When you need a v2 API, duplicate resources rather than adding conditionals everywhere:

App\Http\Resources\V1\UserResource
App\Http\Resources\V2\UserResource
// routes/api.php
Route::prefix('v1')->group(function () {
    Route::get('/users/{user}', [V1\UserController::class, 'show']);
});

Route::prefix('v2')->group(function () {
    Route::get('/users/{user}', [V2\UserController::class, 'show']);
});

Each version owns its contract. Trying to serve both shapes from one resource class becomes unmaintainable fast.

Wrapping and unwrapping

By default, single resources wrap in a data key. Disable globally if your API spec doesn’t use wrapping:

// AppServiceProvider
JsonResource::withoutWrapping();

For mixed endpoints, control wrapping per resource with $wrap or $this->wrap().

Testing resources

Assert the exact JSON shape your frontend expects:

it('transforms a user for the public API', function () {
    $user = User::factory()->create(['name' => 'Laxman']);

    $response = UserResource::make($user)->response()->getData(true);

    expect($response)->toMatchArray([
        'data' => [
            'id' => $user->id,
            'name' => 'Laxman',
            'email' => $user->email,
        ],
    ]);
});

Key takeaways

  • Never return Eloquent models directly from API controllers
  • Use whenLoaded() and whenCounted() to stay N+1-free
  • Keep resources focused on serialization, not business logic
  • Version your resources when breaking changes are unavoidable
  • Eager-load relationships in the controller, not inside the resource loop

API Resources are the contract between your backend and every Vue component, mobile app, and third-party integration. Treat them like a public API—because they are.