Model id: 1 name: "John" email: "j@..." password: *** created_at: ... updated_at: ... Resource toArray() Transform + Filter JSON { "id": 1, "name": "John", "email": "j@.." } Database Transform API Response
Backend

Laravel API Resources: Transforming Data

Mayur Dabhi
Mayur Dabhi
April 2, 2026
18 min read

You write return $user; in a controller, it works, and you move on. A few weeks later someone adds a stripe_customer_id column to the users table, forgets to put it in $hidden, and now every API client can see it. Or the mobile team asks why created_at is a full timestamp on one endpoint and "March 2026" on another. Returning Eloquent models directly ties your JSON to your database schema, and that bites eventually.

API Resources are Laravel's answer: a small class that sits between the model and the response and decides exactly what goes out. This post covers single resources and collections, conditional fields, nested relationships, pagination, wrapping and status codes, plus a couple of e-commerce resources that use all of it together.

What's in here
  • How a Resource turns a model into JSON
  • Resource classes for single models and collections
  • Custom attributes and computed values
  • Conditional attributes and relationship loading
  • Nested resources
  • Pagination and meta data
  • Extra data, wrapping, headers and status codes
  • Habits that keep resources maintainable

Why API Resources Matter

Before Laravel 5.5, most teams either pulled in Fractal or built transformation arrays by hand in controllers. Resources made that part of the framework, so they work with pagination, eager loading and response objects without any glue code. The main win is that your JSON becomes an explicit contract instead of whatever columns happen to exist today.

Without vs With API Resources ❌ Without API Resources return User::all(); Exposed Data "id": 1, "name": "John", "email": "...", "password": "***" "remember_token" "email_verified" "created_at" "updated_at" ⚠️ All fields exposed! ✅ With API Resources UserResource ::collection() Resource Transform Filter Format Add Meta JSON "id": 1, "name": "email": ✓ Clean! ✓ Hide sensitive fields ✓ Custom formatting ✓ Consistent structure ✓ Relationship control

Returning the model sends every visible column. A Resource sends only the fields you list.

Data Protection

New columns don't leak automatically. Passwords, tokens and internal IDs stay out unless you add them.

Consistent Structure

A user looks the same on every endpoint, so frontend and mobile code can share one type.

Decoupled Design

Your database schema and your API contract can change separately. Rename a column, update one line in the Resource, and clients never notice.

Data Transformation

Format dates, compute values, rename fields, and add virtual properties on the fly.

Setting Up the CRUD Endpoints First

Resources shape the JSON, but you need the endpoints first, and Laravel makes that part short. One artisan command creates the model, migration and controller, and one route line registers the CRUD endpoints for posts.

Terminal
php artisan make:model Post -mcr  # model + migration + resource controller
app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class PostController extends Controller
{
    public function index(): JsonResponse
    {
        $posts = Post::latest()->paginate(10);
        return response()->json(['status' => 'success', 'data' => $posts]);
    }

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'title'  => 'required|string|max:255',
            'body'   => 'required|string',
            'author' => 'nullable|string|max:100',
        ]);

        $post = Post::create($data);
        return response()->json(['status' => 'success', 'data' => $post], 201);
    }

    public function show(Post $post): JsonResponse
    {
        return response()->json(['status' => 'success', 'data' => $post]);
    }

    public function update(Request $request, Post $post): JsonResponse
    {
        $data = $request->validate([
            'title'  => 'sometimes|string|max:255',
            'body'   => 'sometimes|string',
            'author' => 'nullable|string|max:100',
        ]);

        $post->update($data);
        return response()->json(['status' => 'success', 'data' => $post]);
    }

    public function destroy(Post $post): JsonResponse
    {
        $post->delete();
        return response()->json(null, 204);
    }
}
routes/api.php
use App\Http\Controllers\PostController;

Route::apiResource('posts', PostController::class);

apiResource maps five controller actions (index, store, show, update, destroy), with PUT and PATCH both going to update. Run php artisan route:list to check. Note that -r also generates create and edit stubs meant for HTML forms; an API doesn't need them, so delete them or use --api instead.

One Laravel gotcha: if a client sends invalid data without an Accept: application/json header, validate() answers with a 302 redirect instead of a 422 JSON response. If your mobile dev reports "random redirects", check that header first.

Creating Your First API Resource

Start with a User model. Artisan generates the class for you:

Terminal
# Create a single resource
php artisan make:resource UserResource

# Create a resource collection (optional - Laravel auto-generates one)
php artisan make:resource UserCollection --collection

That gives you app/Http/Resources/UserResource.php. Here it is with a few real fields filled in:

app/Http/Resources/UserResource.php
<?php

namespace App\Http\Resources;

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

class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'avatar_url' => $this->avatar 
                ? asset('storage/' . $this->avatar) 
                : null,
            'is_verified' => !is_null($this->email_verified_at),
            'member_since' => $this->created_at->format('F Y'),
            'profile_url' => route('users.show', $this->id),
        ];
    }
}
Tip

Inside a Resource, $this->name is proxied to the underlying model, so attributes, accessors, relationships and model methods all work as if you were in the model. If you need the model object itself (to pass to a policy, say), it's in $this->resource.

Using Resources in Controllers

In a controller you return the Resource like any other response. These are the forms I use most:

app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;

class UserController extends Controller
{
    /**
     * Display a single user.
     */
    public function show(User $user)
    {
        // Return a single resource
        return new UserResource($user);
    }

    /**
     * Display all users.
     */
    public function index()
    {
        // Return a collection of resources
        return UserResource::collection(User::all());
    }

    /**
     * Display paginated users.
     */
    public function paginated()
    {
        // Pagination is automatically handled
        return UserResource::collection(User::paginate(15));
    }

    /**
     * Return with additional response data.
     */
    public function withMeta(User $user)
    {
        return (new UserResource($user))
            ->additional([
                'meta' => [
                    'version' => '1.0',
                    'generated_at' => now()->toISOString(),
                ]
            ]);
    }
}

Understanding Resource Architecture

The flow is simple. The controller fetches the model, wraps it in a Resource, and Laravel calls toArray() when it builds the response. Because the Resource gets the current $request, it can shape output per user or per query string.

API Resource Transformation Pipeline HTTP Request GET /api/users/1 Controller Fetches Model Eloquent Model User::find(1) API Resource toArray($request) Transform Data JSON Response { "data": {...} } Resource Class Features $this->model Access underlying model attributes $request Access current HTTP request Conditional Data when(), mergeWhen() whenLoaded() Relationships Nested resources Eager loading

Conditional Attributes

Most real endpoints need the same model shaped slightly differently: admins see more fields, the owner sees their phone number, a list view skips the heavy stuff. Conditional helpers let one Resource handle that without a pile of if blocks. When a condition is false, the key is removed from the output entirely, not set to null.

The when() method includes an attribute only when a condition is true:

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'email' => $this->email,
        
        // Only include for authenticated users
        'phone' => $this->when(
            $request->user()?->id === $this->id,
            $this->phone
        ),
        
        // Include admin-only data
        'internal_notes' => $this->when(
            $request->user()?->isAdmin(),
            $this->internal_notes
        ),
        
        // Include based on query parameter
        'detailed_stats' => $this->when(
            $request->has('include_stats'),
            fn() => $this->calculateDetailedStats()
        ),
    ];
}

Use mergeWhen() to conditionally merge multiple attributes:

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        
        // Merge admin-specific fields
        $this->mergeWhen($request->user()?->isAdmin(), [
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
            'deleted_at' => $this->deleted_at,
            'ip_address' => $this->last_login_ip,
            'login_count' => $this->login_count,
        ]),
        
        // Merge premium user fields
        $this->mergeWhen($this->isPremium(), [
            'subscription_tier' => $this->subscription->tier,
            'features' => $this->getAvailableFeatures(),
        ]),
    ];
}

The whenLoaded() method only includes a relationship if it was eager loaded:

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        
        // Only include if relationship was loaded
        'posts' => PostResource::collection(
            $this->whenLoaded('posts')
        ),
        
        // With a default value
        'comments_count' => $this->whenLoaded(
            'comments',
            fn() => $this->comments->count(),
            0
        ),
        
        // Nested resource
        'profile' => new ProfileResource(
            $this->whenLoaded('profile')
        ),
        
        // Multiple relationships
        'roles' => $this->whenLoaded('roles', function () {
            return $this->roles->pluck('name');
        }),
    ];
}

// In controller - only eager load when needed:
public function index(Request $request)
{
    $query = User::query();
    
    if ($request->has('with_posts')) {
        $query->with('posts');
    }
    
    return UserResource::collection($query->get());
}
N+1 Query Warning

If a Resource touches $this->posts directly and the controller didn't eager load it, every item in a collection runs its own query. Wrap relationships in whenLoaded() and load them with with() in the controller. To catch the mistakes early, call Model::preventLazyLoading(! app()->isProduction()); in AppServiceProvider::boot(). In local and test environments you'll then get an exception like Attempted to lazy load [posts] on model [App\Models\User] but lazy loading is disabled. instead of a silently slow endpoint.

Nested Resources and Relationships

A post has an author, a category, tags and comments. You don't need a special mechanism for that: a Resource can return other Resources, and each one formats its own model.

app/Http/Resources/PostResource.php
<?php

namespace App\Http\Resources;

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

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => $this->excerpt,
            'content' => $this->when(
                !$request->is('api/posts'), // Full content on detail view
                $this->content
            ),
            'featured_image' => $this->featured_image 
                ? asset('storage/' . $this->featured_image)
                : null,
            'reading_time' => $this->calculateReadingTime(),
            'published_at' => $this->published_at?->format('M d, Y'),
            'is_featured' => $this->is_featured,
            
            // Nested author resource
            'author' => new UserResource($this->whenLoaded('author')),
            
            // Nested category resource
            'category' => new CategoryResource($this->whenLoaded('category')),
            
            // Collection of nested resources
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            
            // Nested comments with replies (recursive)
            'comments' => CommentResource::collection(
                $this->whenLoaded('comments')
            ),
            
            // Counts (only when loaded)
            'likes_count' => $this->whenCounted('likes'),
            'comments_count' => $this->whenCounted('comments'),
            
            // Links for HATEOAS
            'links' => [
                'self' => route('api.posts.show', $this->id),
                'author' => route('api.users.show', $this->author_id),
            ],
        ];
    }
    
    private function calculateReadingTime(): string
    {
        $words = str_word_count(strip_tags($this->content));
        $minutes = ceil($words / 200);
        return $minutes . ' min read';
    }
}
Nested Resource Structure PostResource "id": 1 "title": "Laravel Tips" "slug": "laravel-tips" UserResource "author": { "id": 5, "name": "John" } CategoryResource "category": { "id": 3, "name": "PHP" } TagResource::collection() "tags": [ { "id": 1, "name": "Laravel" }, { "id": 2, "name": "API" } ] Legend Parent Resource BelongsTo HasMany

Resource Collections

For lists, UserResource::collection() covers most cases. Write a dedicated ResourceCollection class only when the list itself needs extra fields, like aggregate meta or top-level links:

app/Http/Resources/UserCollection.php
<?php

namespace App\Http\Resources;

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

class UserCollection extends ResourceCollection
{
    /**
     * The resource that this resource collects.
     */
    public $collects = UserResource::class;

    /**
     * Transform the resource collection into an array.
     */
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total_users' => $this->collection->count(),
                'verified_users' => $this->collection
                    ->filter(fn($user) => $user->email_verified_at)
                    ->count(),
                'generated_at' => now()->toISOString(),
            ],
        ];
    }
    
    /**
     * Add additional data to the response.
     */
    public function with(Request $request): array
    {
        return [
            'links' => [
                'self' => route('api.users.index'),
            ],
            'api_version' => '2.0',
        ];
    }
}

Pagination with Resources

Pass a paginator instead of a plain collection and Laravel adds the links and meta blocks for you:

Controller with Pagination
// In your controller
public function index(Request $request)
{
    $users = User::query()
        ->with(['profile', 'roles'])
        ->when($request->search, function ($query, $search) {
            $query->where('name', 'like', "%{$search}%");
        })
        ->paginate($request->per_page ?? 15);
    
    return UserResource::collection($users);
}

// Response structure:
{
    "data": [
        { "id": 1, "name": "John", ... },
        { "id": 2, "name": "Jane", ... }
    ],
    "links": {
        "first": "http://api.example.com/users?page=1",
        "last": "http://api.example.com/users?page=5",
        "prev": null,
        "next": "http://api.example.com/users?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 5,
        "links": [...],
        "path": "http://api.example.com/users",
        "per_page": 15,
        "to": 15,
        "total": 72
    }
}

Advanced Resource Customization

Customizing the Data Wrapper

By default, Resources wrap responses in a "data" key. You can rename it or turn it off:

Customizing the wrapper
// Disable wrapping for a single resource
class UserResource extends JsonResource
{
    public static $wrap = null; // No wrapper
    // OR
    public static $wrap = 'user'; // Custom wrapper key
}

// Disable wrapping globally in AppServiceProvider
use Illuminate\Http\Resources\Json\JsonResource;

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

// Override for specific responses
return (new UserResource($user))->response()->setData([
    'user' => (new UserResource($user))->resolve(),
    'permissions' => $user->permissions,
]);
Gotcha: paginated responses keep "data"

withoutWrapping() only affects the outer wrapper of single resources and plain collections. When you return a paginated collection, Laravel still puts the items under data, because links and meta need somewhere to sit next to them. If your frontend expects a bare array everywhere, it will break on the first paginated endpoint.

Response Headers and Status Codes

Customizing HTTP Response
public function store(Request $request)
{
    $user = User::create($request->validated());
    
    return (new UserResource($user))
        ->response()
        ->setStatusCode(201)
        ->header('X-Created-By', $request->user()->id)
        ->header('Location', route('api.users.show', $user));
}

// Or using the withResponse method in the Resource
class UserResource extends JsonResource
{
    public function withResponse(Request $request, JsonResponse $response): void
    {
        if ($this->wasRecentlyCreated) {
            $response->setStatusCode(201);
            $response->header('Location', route('api.users.show', $this->id));
        }
        
        $response->header('X-Resource-Version', '1.0');
    }
}

Habits That Keep Resources Maintainable

1

Always Use Resources for API Responses

Don't return raw Eloquent models from API routes. Give every model that shows up in a response its own Resource, even when it looks like a copy of the table at first. The value shows up the day the table changes.

2

Use whenLoaded() for Relationships

Wrap every relationship in whenLoaded(). Then a relationship only appears when the controller eager loaded it, and a Resource can never trigger a surprise query.

3

Create Separate Resources for Different Contexts

Once a Resource has more when() calls than plain fields, split it. A UserResource for public data and a UserDetailResource for the logged-in user's own profile is easier to read than one class full of conditions.

4

Use Type Hints and Return Types

Keep the Request $request and : array signatures from the generated stub. Your IDE and static analysis tools (PHPStan, Larastan) can then check what you return.

5

Keep Transformations Simple

A Resource formats data. It shouldn't decide prices or permissions. Put real calculations in model methods, accessors or service classes, and have the Resource just read the result.

Real-World Example: E-Commerce API

Here are two Resources from a typical shop API. They use most of what's above: conditional fields, admin-only data, nested resources and links.

ProductResource.php

<?php

namespace App\Http\Resources;

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

class ProductResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'slug' => $this->slug,
            'description' => $this->when(
                $request->routeIs('api.products.show'),
                $this->description
            ),
            
            // Pricing with currency formatting
            'price' => [
                'amount' => $this->price,
                'formatted' => $this->formatted_price,
                'currency' => 'USD',
            ],
            
            // Sale price (conditional)
            $this->mergeWhen($this->isOnSale(), [
                'sale_price' => [
                    'amount' => $this->sale_price,
                    'formatted' => $this->formatted_sale_price,
                    'discount_percent' => $this->discount_percentage,
                ],
            ]),
            
            // Stock information
            'stock' => [
                'available' => $this->stock_quantity,
                'status' => $this->stockStatus(),
                'low_stock_alert' => $this->stock_quantity < 10,
            ],
            
            // Images
            'images' => ImageResource::collection($this->whenLoaded('images')),
            'thumbnail' => $this->thumbnail_url,
            
            // Relationships
            'category' => new CategoryResource($this->whenLoaded('category')),
            'brand' => new BrandResource($this->whenLoaded('brand')),
            'variants' => VariantResource::collection($this->whenLoaded('variants')),
            
            // Reviews summary
            'reviews' => [
                'average_rating' => round($this->reviews_avg_rating, 1),
                'total_count' => $this->reviews_count,
                'breakdown' => $this->when(
                    $request->routeIs('api.products.show'),
                    fn() => $this->getReviewBreakdown()
                ),
            ],
            
            // Admin-only fields
            $this->mergeWhen($request->user()?->isAdmin(), [
                'cost_price' => $this->cost_price,
                'profit_margin' => $this->profit_margin,
                'supplier' => new SupplierResource($this->whenLoaded('supplier')),
            ]),
            
            // Timestamps
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->when(
                $request->user()?->isAdmin(),
                $this->updated_at->toISOString()
            ),
            
            // HATEOAS links
            'links' => [
                'self' => route('api.products.show', $this->slug),
                'add_to_cart' => route('api.cart.add', $this->id),
                'reviews' => route('api.products.reviews', $this->slug),
            ],
        ];
    }
    
    private function stockStatus(): string
    {
        return match(true) {
            $this->stock_quantity === 0 => 'out_of_stock',
            $this->stock_quantity < 10 => 'low_stock',
            default => 'in_stock',
        };
    }
}

OrderResource.php

<?php

namespace App\Http\Resources;

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

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'order_number' => $this->order_number,
            'status' => [
                'code' => $this->status,
                'label' => $this->status_label,
                'color' => $this->status_color,
            ],
            
            // Order items
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
            'items_count' => $this->whenCounted('items'),
            
            // Pricing
            'totals' => [
                'subtotal' => $this->subtotal,
                'shipping' => $this->shipping_cost,
                'tax' => $this->tax_amount,
                'discount' => $this->discount_amount,
                'total' => $this->total,
                'currency' => 'USD',
            ],
            
            // Addresses
            'shipping_address' => new AddressResource(
                $this->whenLoaded('shippingAddress')
            ),
            'billing_address' => new AddressResource(
                $this->whenLoaded('billingAddress')
            ),
            
            // Customer (visible to admin)
            'customer' => $this->when(
                $request->user()?->isAdmin(),
                fn() => new UserResource($this->whenLoaded('user'))
            ),
            
            // Tracking
            'shipping' => $this->when($this->shipped_at, [
                'carrier' => $this->shipping_carrier,
                'tracking_number' => $this->tracking_number,
                'tracking_url' => $this->tracking_url,
                'shipped_at' => $this->shipped_at?->toISOString(),
                'estimated_delivery' => $this->estimated_delivery?->format('M d, Y'),
            ]),
            
            // Timestamps
            'placed_at' => $this->created_at->toISOString(),
            'completed_at' => $this->completed_at?->toISOString(),
            
            // Actions
            'can' => [
                'cancel' => $this->canBeCancelled(),
                'return' => $this->canBeReturned(),
                'review' => $this->canLeaveReview(),
            ],
        ];
    }
}

Performance Tips

Practice Why It Matters Implementation
Eager Load Relationships Prevents N+1 queries when serializing collections User::with(['posts', 'profile'])->get()
Use withCount() Get counts without loading full relationships Post::withCount('comments')->get()
Select Specific Columns Reduce memory usage for large datasets User::select(['id', 'name', 'email'])->get()
Lazy Evaluation with Closures Defer expensive operations until needed $this->when($cond, fn() => $expensive)
Cache Computed Values Avoid recalculating on every request Use model accessors with caching

When I'd Use Resources (and When I Wouldn't)

For any JSON that leaves your app and gets consumed by code you don't control (a mobile app, a SPA built by another team, a public API), I'd use Resources from day one. Writing them is cheap, and they're the only thing standing between a schema migration and a broken client.

The trade-offs

  • Skip them for internal one-offs. An admin-only endpoint that feeds a single Blade page or Livewire component doesn't need a contract layer. $model->only([...]) is fine there.
  • Watch big exports. Serializing tens of thousands of rows through Resources builds every array in memory. For CSV exports or bulk sync endpoints, stream with lazy() or cursor() and write rows yourself.
  • Conditionals hide the response shape. With lots of when() calls, nobody can tell what a response looks like without reading code. Write feature tests that assert the JSON structure with assertJsonStructure(), or split the Resource.
  • They're not versioning. Resources make a v2 easier (a V2\UserResource next to the old one), but you still need a plan for routes and deprecation.

If you're adding Resources to an existing API, start with the endpoint that returns the most sensitive model, usually users. Check that its output matches what clients get today, then work outward from there.

Laravel API Resources JSON REST PHP Backend
Mayur Dabhi

Mayur Dabhi

Full-stack developer specializing in Laravel, React, and modern web technologies. Passionate about building scalable applications and sharing knowledge with the developer community.