🎁 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
JsonResourcefor 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 customResourceCollection
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.
📖 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.
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
- Generate
UserResourcewithphp artisan make:resource UserResource. - Output a computed
full_name(combinefirst_nameandlast_name). - Format
created_atas an ISO-8601 string. - Include the
emailonly when the requester is an admin or the user themselves. - Attach the user's
postswithPostResource::collection($this->whenLoaded('posts')). - Return it from a
UserController@showthat eager-loadsposts.
💡 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 customResourceCollectionfor collection-level metadata. whenLoaded()embeds relationships safely and prevents N+1 queries.when()exposes fields conditionally;with()attaches top-level metadata.
📚 Further Reading
- Laravel Docs — Eloquent: API Resources
- Laravel Docs — Eager Loading
- JSON:API — a resource formatting specification
🚀 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.