Skip to main content

🎁 API Resources and Transformations

Your database rows are rarely the shape you want to hand to a client. API Resources are Laravel's dedicated transformation layer — a clean, testable place to rename fields, format values, hide secrets, and attach relationships, so every endpoint speaks the same polished JSON.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why a transformation layer sits between your models and your JSON
  • Generate and use a JsonResource for single models and collections
  • Rename, format, and compute fields in toArray()
  • Attach relationships efficiently with whenLoaded() to avoid N+1 queries
  • Expose fields conditionally with when() based on role or request
  • Add response metadata with with() and build a custom ResourceCollection

Estimated Time: 40–55 minutes  •  Difficulty: Intermediate

Hands-on: Build a UserResource with computed, conditional, and related fields.

In This Lesson

Why Resources Exist

In the previous lesson we returned models straight from the controller with response()->json($product). That works, but it has real problems: it leaks every column (including cost_price and supplier_id), it uses your raw database field names, and the JSON shape is scattered across every controller action. Change the format once and you're hunting through the whole app.

API Resources fix this. A resource is a small class whose only job is to turn a model into an array. Your controller returns the resource; the resource decides the shape.

💡 Analogy: Think of a resource as a professional gift-wrapper. The database hands you the raw product; the resource wraps it so only what the recipient should see is presented — nicely formatted, nothing private poking out.
The API Resource transformation layer An Eloquent model with many raw columns flows into an API Resource, which outputs a clean, shaped JSON response exposing only chosen fields. Eloquent Model id, name, price, cost_price, supplier_id, created_at, ... API Resource toArray() shapes it JSON Response id, name, formatted_price, in_stock
Figure 1 — The resource sits between the model and the response, exposing only the fields you choose, in the format you choose.

📖 Why teams reach for resources

Consistency — one place defines the shape. Security — private columns never leak by accident. Flexibility — rename, format, and compute freely. Versioning — namespace resources per API version.

Creating & Using a Resource

Generate one with Artisan. It lands in app/Http/Resources:

php artisan make:resource ProductResource
<?php
// app/Http/Resources/ProductResource.php
namespace App\Http\Resources;

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

class ProductResource 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,
            'price'       => $this->price,
            'description' => $this->description,
            'created_at'  => $this->created_at,
        ];
    }
}

Inside the resource, $this transparently proxies to the underlying model, so $this->name reads the model's name. In the controller you wrap a single model in the resource, or use ::collection() for many:

<?php
// app/Http/Controllers/Api/ProductController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\ProductResource;
use App\Models\Product;

class ProductController extends Controller
{
    public function show(Product $product): ProductResource
    {
        return new ProductResource($product);
    }

    public function index()
    {
        // Pagination is preserved automatically
        return ProductResource::collection(Product::paginate(15));
    }
}

💡 The data wrapper

By default a resource nests its output under a data key — exactly the envelope we hand-wrote last lesson, now for free. When you pass a paginated collection, Laravel also adds links and meta with the pagination details.

Transformation Techniques

The whole point of a resource is that toArray() is ordinary PHP — you can reshape the data however you like.

Rename fields

Give the API a public vocabulary that doesn't have to match your columns:

<?php
return [
    'id'           => $this->id,
    'product_name' => $this->name,        // renamed from "name"
    'details'      => $this->description, // renamed from "description"
];

Format values

Send clients data that's ready to display — grouped and pre-formatted:

<?php
return [
    'id'    => $this->id,
    'name'  => $this->name,
    'price' => [
        'amount'    => $this->price,
        'formatted' => '$' . number_format($this->price, 2),
    ],
    'created_at' => $this->created_at->toIso8601String(),
];

Computed properties

Add values that don't exist as columns at all:

<?php
return [
    'id'           => $this->id,
    'name'         => $this->name,
    'price'        => $this->price,
    'sale_price'   => round($this->price * (1 - $this->discount / 100), 2),
    'in_stock'     => $this->quantity > 0,
    'stock_status' => match (true) {
        $this->quantity > 10 => 'high',
        $this->quantity > 0  => 'low',
        default             => 'out_of_stock',
    },
];

Resource Collections

For lists you have two choices. The quick one is ::collection(), which wraps each item in the resource:

<?php
return ProductResource::collection(Product::paginate(15));

When you want collection-level metadata — a count, a store name, custom links — generate a dedicated collection class:

php artisan make:resource ProductCollection
<?php
// app/Http/Resources/ProductCollection.php
namespace App\Http\Resources;

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

class ProductCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total_products' => $this->collection->count(),
                'store_name'     => 'My Awesome Store',
                'api_version'    => '1.0',
            ],
        ];
    }
}
<?php
// In the controller
return new ProductCollection(Product::paginate(15));

Relationships & whenLoaded

Resources can embed related resources — a product's category, its tags, its reviews. The key method is whenLoaded(), which includes the relationship only if it was eager-loaded. This is what keeps you safe from the dreaded N+1 query problem.

<?php
// In ProductResource
public function toArray(Request $request): array
{
    return [
        'id'       => $this->id,
        'name'     => $this->name,
        'category' => new CategoryResource($this->whenLoaded('category')),
        'tags'     => TagResource::collection($this->whenLoaded('tags')),
        'reviews'  => ReviewResource::collection($this->whenLoaded('reviews')),
    ];
}

Then eager-load in the controller so the relationships are present when the resource asks for them:

<?php
public function show(Product $product): ProductResource
{
    $product->load(['category', 'tags', 'reviews']);
    return new ProductResource($product);
}

⚠️ N+1: the bug whenLoaded prevents

If you call new CategoryResource($this->category) directly (no whenLoaded) inside a list of 100 products, Laravel runs one query per product to fetch its category — 100 extra queries. With whenLoaded plus ->with('category') on the query, it's a single extra query for all 100. Always eager-load, always gate with whenLoaded.

flowchart LR A[Controller] -->|with 'category','tags'| B[Eloquent query] B --> C[Models + relations loaded] C --> D[Resource: whenLoaded] D --> E[JSON with nested data]

Conditional Attributes

Not every client should see every field. The when() method includes a key only when a condition is true — the key is completely absent otherwise, not null.

Based on the authenticated user

<?php
return [
    'id'    => $this->id,
    'name'  => $this->name,
    'price' => $this->price,
    // Only admins see the internal cost
    'cost'  => $this->when($request->user()?->isAdmin(), $this->cost),
];

Based on a request parameter

Let clients opt into a heavier payload with ?detailed=1. Note when() can take a fallback as its third argument:

<?php
use Illuminate\Support\Str;

return [
    'id'   => $this->id,
    'name' => $this->name,
    'description' => $this->when(
        $request->boolean('detailed'),
        $this->description,                       // full text when detailed=1
        fn () => Str::limit($this->description, 100) // otherwise a preview
    ),
];

💡 Related helpers

whenNotNull() includes a value only if it isn't null; whenLoaded() is the relationship-specific version of when(); mergeWhen() conditionally merges several keys at once. They all share the same "omit the key entirely when false" behavior.

Metadata & Wrapping

Use the with() method to attach top-level data that isn't part of the resource itself — an API version, server time, or documentation links. It's merged into the outer response next to data:

<?php
// In ProductResource
public function with(Request $request): array
{
    return [
        'meta' => [
            'api_version' => '1.0',
            'server_time' => now()->toIso8601String(),
        ],
    ];
}

Resulting JSON

{
  "data": {
    "id": 1,
    "name": "Awesome Product",
    "price": 99.99
  },
  "meta": {
    "api_version": "1.0",
    "server_time": "2026-08-01T12:00:00+00:00"
  }
}

If your API convention doesn't use the data wrapper, disable it globally — typically in a service provider's boot() method:

<?php
use Illuminate\Http\Resources\Json\JsonResource;

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

Worked Example: A Rich Product Resource

Putting it all together — grouped data, computed fields, conditional attributes, eager-loaded relationships, and metadata in one realistic resource:

<?php
// app/Http/Resources/ProductResource.php
namespace App\Http\Resources;

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

class ProductResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'   => $this->id,
            'slug' => $this->slug,
            'name' => $this->name,

            'pricing' => [
                'amount'    => $this->price,
                'formatted' => '$' . number_format($this->price, 2),
                'on_sale'   => $this->discount > 0,
                'sale_price' => $this->when(
                    $this->discount > 0,
                    fn () => round($this->price * (1 - $this->discount / 100), 2)
                ),
            ],

            'inventory' => [
                'in_stock' => $this->quantity > 0,
                // Vendors alone see the exact quantity on hand
                'quantity' => $this->when(
                    $request->user()?->isVendor(),
                    $this->quantity
                ),
            ],

            'description' => $this->when(
                $request->boolean('detailed'),
                $this->description,
                fn () => Str::limit($this->description, 150)
            ),

            'category' => new CategoryResource($this->whenLoaded('category')),
            'tags'     => TagResource::collection($this->whenLoaded('tags')),

            'ratings' => $this->when($this->relationLoaded('reviews'), fn () => [
                'average' => round($this->reviews->avg('rating'), 1),
                'count'   => $this->reviews->count(),
            ]),

            'created_at' => $this->created_at->toIso8601String(),
        ];
    }

    public function with(Request $request): array
    {
        return ['meta' => ['currency' => 'USD']];
    }
}

The controller stays trivial — its only job is to fetch efficiently and hand off:

<?php
public function show(Product $product): ProductResource
{
    $product->load(['category', 'tags', 'reviews']);
    return new ProductResource($product);
}

Hands-on Exercise

🏋️ Build a UserResource

Objective: Transform a User model into a clean, safe, feature-rich API response.

Requirements

  1. Generate UserResource with php artisan make:resource UserResource.
  2. Output a computed full_name (combine first_name and last_name).
  3. Format created_at as an ISO-8601 string.
  4. Include the email only when the requester is an admin or the user themselves.
  5. Attach the user's posts with PostResource::collection($this->whenLoaded('posts')).
  6. Return it from a UserController@show that eager-loads posts.
💡 Hint

For the email rule, combine two checks with the null-safe operator: $this->when($request->user()?->isAdmin() || $request->user()?->is($this->resource), $this->email). Remember when() omits the key entirely when the condition is false.

✅ Sample solution
<?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
    {
        $viewer = $request->user();

        return [
            'id'        => $this->id,
            'full_name' => trim("{$this->first_name} {$this->last_name}"),
            'email'     => $this->when(
                $viewer?->isAdmin() || $viewer?->is($this->resource),
                $this->email
            ),
            'posts'      => PostResource::collection($this->whenLoaded('posts')),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

// Controller
public function show(User $user): UserResource
{
    return new UserResource($user->load('posts'));
}

🎯 Quick Quiz

Question 1: What is the main job of an API Resource?

Question 2: Why use whenLoaded('category') instead of $this->category directly?

Question 3: When a when() condition is false, what happens to that key?

Best Practices

✅ Do

  • Keep a consistent structure across resources; group related fields (pricing, inventory).
  • Always pair whenLoaded() in the resource with ->with() or ->load() in the query.
  • Use when() to hide sensitive fields rather than building separate resources for each role.
  • Paginate collections and let the resource preserve links/meta.
  • Namespace resources (e.g. Http\Resources\V1) when you version the API.

❌ Don't

  • Return raw models from controllers — you'll leak private columns.
  • Access relationships without whenLoaded() inside a collection (hello, N+1).
  • Put heavy business logic in toArray(); keep it to shaping and light computation.
  • Duplicate the same JSON shaping across multiple controllers.

Summary & Quiz

🎉 Key Takeaways

  • An API Resource is a transformation layer between your models and your JSON — one place that owns the response shape.
  • toArray() is plain PHP: rename, format, and compute fields freely.
  • Use ::collection() for lists, or a custom ResourceCollection for collection-level metadata.
  • whenLoaded() embeds relationships safely and prevents N+1 queries.
  • when() exposes fields conditionally; with() attaches top-level metadata.

📚 Further Reading

🚀 What's Next?

Your API now returns beautifully shaped JSON — but anyone can call it. In the next lesson, API Authentication with Passport, you'll add a full OAuth2 server so only clients with a valid token can reach your protected endpoints.

🎉 Nicely wrapped!

Your responses are clean, consistent, and safe. Time to lock the door with authentication.