Laravel API Resources: Transforming Data
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.
- 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.
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.
php artisan make:model Post -mcr # model + migration + resource controller
<?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);
}
}
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:
# 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:
<?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),
];
}
}
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:
<?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.
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());
}
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.
<?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';
}
}
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:
<?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:
// 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:
// 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,
]);
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
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
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.
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.
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.
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.
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()orcursor()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 withassertJsonStructure(), or split the Resource. - They're not versioning. Resources make a v2 easier (a
V2\UserResourcenext 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.