Laravel API Resources and RESTful API Development
API Resources is Laravel's "data wrapper"—it controls which fields are exposed to clients, how they are formatted, and how relationships are nested, ensuring that API output follows a consistent standard.
1. What You'll Learn
- Resource Class and ResourceCollection: Data Conversion and Formatting
- Nested associated resources: Conditional loading of associated resources via
whenLoaded() - Encapsulation of Pagination, Filtering, and Sorting for API Resources
- RESTful API Design Specifications: URIs, Verbs, Status Codes, and Error Formats
- API Version Control: v1/v2 Route Groups and Resource Inheritance
2. A True Story of a Front-End Developer
(1) Pain Point: The JSON format returned by the API causes the front end to crash
Alice ran into a nightmare while integrating with the ShopMetrics API—/shops returned {shops: [...]}, but /orders returned {data: [...]}, and /products simply returned an array. The field names weren't consistent either: some used created_at, some used createdAt, and others used createdDate. Passwords and internal IDs were also exposed in the JSON. There was more front-end adaptation code than business logic.
(2) Solution for API Resources
API Resources uniformly control the JSON output format—ensuring consistent field names, hiding sensitive fields, nesting association conditions, and adhering to standard pagination formats.
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'status' => $this->status,
'products' => ProductResource::collection($this->whenLoaded('products')),
'created_at' => $this->created_at->toISOString(),
];
}
}
(3) Revenue
After Alice started using API Resources, all endpoints were standardized, front-end adaptation code was reduced by 80%, and sensitive fields such as passwords were automatically hidden.
3. The Resource Class and ResourceCollection
(1) Create a Resource
php artisan make:resource ShopResource
php artisan make:resource ProductResource
php artisan make:resource OrderResource
# Creates: app/Http/Resources/ShopResource.php
(2) Resource Class (Single Record)
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'status' => $this->status,
'revenue' => (float) $this->revenue,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
// Usage — single resource
return new ShopResource($shop);
// {"data": {"id": 1, "name": "Alice Store", ...}}
(3) ResourceCollection (Multiple Records)
// Using resource collection
return ShopResource::collection($shops);
// {"data": [...], "links": {...}, "meta": {...}}
// Custom collection
php artisan make:resource ShopCollection
class ShopCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_shops' => $this->collection->count(),
],
];
}
}
| Type | Return Format | Suitable For |
|---|---|---|
new Resource($model) |
{data: {...}} |
Single Record |
Resource::collection($models) |
{data: [...], links, meta} |
List + Pagination |
CustomCollection |
Custom Format | Requires additional meta |
(1) ▶ Example: ShopMetrics ShopResource
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->whenNotNull($this->description),
'status' => $this->status,
'revenue' => [
'raw' => (float) $this->revenue,
'formatted' => $this->revenue_formatted,
],
'products_count' => $this->whenCounted('products'),
'orders_count' => $this->whenCounted('orders'),
'products' => ProductResource::collection($this->whenLoaded('products')),
'latest_order' => new OrderResource($this->whenLoaded('latestOrder')),
'links' => [
'self' => route('api.v1.shops.show', $this->id),
'products' => route('api.v1.products.index', ['shop_id' => $this->id]),
],
'created_at' => $this->created_at->toISOString(),
];
}
}
Output:
// Execution Successful
4. Nested Related Resources
(1) whenLoaded() Conditional Loading
// Only include relation if it was eager-loaded
'products' => ProductResource::collection($this->whenLoaded('products')),
// If products were not loaded with(), this returns null and is omitted
// Shop::find(1) → no products key in JSON
// Shop::with('products')->find(1) → products included
// Single relation
'user' => new UserResource($this->whenLoaded('user')),
// Count only (no data)
'products_count' => $this->whenCounted('products'),
(2) Defining Nested Resources
// app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'total' => (float) $this->total,
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shop' => new ShopBriefResource($this->whenLoaded('shop')),
'user' => new UserBriefResource($this->whenLoaded('user')),
'created_at' => $this->created_at->toISOString(),
];
}
}
// Brief resource — minimal data for nesting
class ShopBriefResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
];
}
}
| Method | Description | How to Avoid Problems |
|---|---|---|
whenLoaded() |
Output only after joins have been loaded | Avoid N+1 with lazy loading |
whenCounted() |
Output only after counting | Avoid extra queries |
whenNotNull() |
Output only if not null | Clean up empty fields |
when() |
Conditional Output | Flexible Control |
| Brief Resource | Use the Lite Version for Nesting | Avoid Nested Loops |
(1) ▶ Example: ShopMetrics Nested Resources
// app/Http/Resources/OrderItemResource.php
class OrderItemResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product' => new ProductBriefResource($this->whenLoaded('product')),
'quantity' => $this->quantity,
'unit_price' => (float) $this->price,
'subtotal' => (float) ($this->price * $this->quantity),
];
}
}
// app/Http/Resources/ProductBriefResource.php
class ProductBriefResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'sku' => $this->sku,
];
}
}
Output:
// Execution Successful
5. RESTful API Design Specifications
(1) URIs and HTTP Verbs
| Operation | HTTP | URI | Description |
|---|---|---|---|
| List | GET | /api/v1/shops | Returns a collection |
| Create | POST | /api/v1/shops | Create resource |
| Details | GET | /api/v1/shops/{id} | Return a single record |
| Full Update | PUT | /api/v1/shops/{id} | Replace Resource |
| Partial Update | PATCH | /api/v1/shops/{id} | Modify Fields |
| Delete | DELETE | /api/v1/shops/{id} | Delete resource |
(2) HTTP Status Codes
| Status Code | Meaning | Use Case |
|---|---|---|
| 200 | OK | Successful response |
| 201 | Created | Resource created successfully |
| 204 | No Content | Deleted successfully |
| 400 | Bad Request | Invalid request format |
| 401 | Unauthorized | Not Authenticated |
| 403 | Forbidden | No Permission |
| 404 | Not Found | Resource does not exist |
| 422 | Unprocessable Entity | Validation Failed |
| 429 | Too Many Requests | Rate Limiting |
| 500 | Internal Server Error | Server Error |
(3) Incorrect Response Format
{
"success": false,
"message": "Validation failed.",
"errors": {
"name": ["The name field is required."],
"email": ["The email must be a valid email address."]
}
}
(1) ▶ Example: ShopMetrics RESTful API Endpoint Design
// routes/api.php
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
// Shops
Route::apiResource('shops', Api\V1\ShopController::class);
Route::get('shops/{shop}/analytics', [Api\V1\ShopAnalyticsController::class, 'show']);
// Products (nested then shallow)
Route::apiResource('shops.products', Api\V1\ProductController::class)->shallow();
// Orders
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show', 'update']);
Route::post('orders/{order}/cancel', [Api\V1\OrderController::class, 'cancel']);
// Categories
Route::apiResource('categories', Api\V1\CategoryController::class)->only(['index', 'show']);
// Analytics
Route::get('analytics/overview', [Api\V1\AnalyticsController::class, 'overview']);
});
Output:
// Execution Successful
6. Filtering, Sorting, and Pagination Encapsulation
(1) Query Filter Base Class
// app/Filters/QueryFilter.php
abstract class QueryFilter
{
public function __construct(protected Request $request) {}
public function apply(Builder $query): Builder
{
foreach ($this->filters() as $filter => $value) {
if (method_exists($this, $filter) && $value !== null) {
$this->$filter($query, $value);
}
}
return $query;
}
protected function filters(): array
{
return $this->request->all();
}
}
(2) Specific Filter
// app/Filters/ShopFilter.php
class ShopFilter extends QueryFilter
{
public function search(Builder $query, string $value): Builder
{
return $query->where('name', 'like', "%{$value}%");
}
public function status(Builder $query, string $value): Builder
{
return $query->where('status', $value);
}
public function min_revenue(Builder $query, float $value): Builder
{
return $query->where('revenue', '>=', $value);
}
public function sort(Builder $query, string $value): Builder
{
$direction = str_starts_with($value, '-') ? 'desc' : 'asc';
$field = ltrim($value, '-');
return $query->orderBy($field, $direction);
}
}
(1) ▶ Example: ShopMetrics API Endpoints with Filters
// app/Http/Controllers/Api/V1/ShopController.php
class ShopController extends Controller
{
public function index(ShopFilter $filter): JsonResponse
{
$shops = Shop::where('tenant_id', tenant()->id)
->filter($filter)
->withCount(['products', 'orders'])
->paginate(request()->integer('per_page', 15));
return ShopResource::collection($shops);
}
public function store(StoreShopRequest $request): JsonResponse
{
$shop = Shop::create(array_merge($request->validated(), ['tenant_id' => tenant()->id]));
return response()->json([
'message' => 'Shop created.',
'data' => new ShopResource($shop),
], 201);
}
public function show(Shop $shop): JsonResponse
{
$shop->load(['products' => fn ($q) => $q->active()->latest()->take(10)]);
return new ShopResource($shop);
}
public function update(UpdateShopRequest $request, Shop $shop): JsonResponse
{
$shop->update($request->validated());
return new ShopResource($shop);
}
public function destroy(Shop $shop): Response
{
$shop->delete();
return response()->noContent();
}
}
Output:
// Execution Successful
7. API Version Control
(1) Routing Group Version
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
});
Route::prefix('v2')->group(function () {
Route::apiResource('shops', Api\V2\ShopController::class);
});
(2) Resource Inheritance
// V1 ShopResource
namespace App\Http\Resources\Api\V1;
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
];
}
}
// V2 ShopResource — extends and adds fields
namespace App\Http\Resources\Api\V2;
class ShopResource extends \App\Http\Resources\Api\V1\ShopResource
{
public function toArray(Request $request): array
{
return array_merge(parent::toArray($request), [
'revenue' => (float) $this->revenue,
'products_count' => $this->whenCounted('products'),
'links' => [
'self' => route('api.v2.shops.show', $this->id),
],
]);
}
}
| Versioning Strategy | Approach | Pros and Cons |
|---|---|---|
| URL Prefix | /api/v1/, /api/v2/ |
✅ Simple and Clear |
| Header | Accept: application/vnd.api.v2+json |
More RESTful but More Complex |
| Query | ?version=2 |
Not recommended, not RESTful |
(1) ▶ Example: ShopMetrics API Version Routing
// routes/api.php
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::apiResource('shops', Api\V1\ShopController::class);
Route::apiResource('products', Api\V1\ProductController::class);
Route::apiResource('orders', Api\V1\OrderController::class)->only(['index', 'show']);
});
Route::prefix('v2')->middleware('auth:sanctum')->group(function () {
Route::apiResource('shops', Api\V2\ShopController::class);
Route::apiResource('products', Api\V2\ProductController::class);
Route::apiResource('orders', Api\V2\OrderController::class);
// V2 adds full CRUD for orders + analytics
Route::get('analytics', [Api\V2\AnalyticsController::class, 'overview']);
});
Output:
// Execution Successful
8. Comprehensive Example: The Complete Process for ShopMetrics API Resources
// ============================================
// Comprehensive: ShopMetrics API Resources
// Covers: resources, relations, filtering, pagination, versioning
// ============================================
// app/Http/Resources/Api/V1/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'order_number' => $this->order_number,
'status' => $this->status,
'subtotal' => (float) $this->subtotal,
'discount' => (float) $this->discount,
'total' => (float) $this->total,
'items_count' => $this->whenCounted('items'),
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shop' => new ShopBriefResource($this->whenLoaded('shop')),
'user' => [
'id' => $this->whenLoaded('user')?->id,
'name' => $this->whenLoaded('user')?->name,
],
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
// Controller with full pipeline
class OrderController extends Controller
{
public function index(OrderFilter $filter): JsonResponse
{
$orders = Order::where('tenant_id', tenant()->id)
->filter($filter)
->with(['shop', 'user'])
->withCount('items')
->latest()
->paginate(request()->integer('per_page', 15));
return OrderResource::collection($orders);
}
public function show(Order $order): JsonResponse
{
$order->load(['items.product', 'shop', 'user']);
return new OrderResource($order);
}
public function update(UpdateOrderRequest $request, Order $order): JsonResponse
{
$order->update($request->validated());
return new OrderResource($order->fresh()->load('items.product'));
}
}
❓ FAQ
Resource and directly returning the model JSON?Resource allows for precise control over output fields, formatted values, and nested relationships. APIs must use Resource.whenLoaded and direct access to a relationship?whenLoaded only outputs the relationship if it has been preloaded; if it hasn't been loaded, it returns null and is automatically removed from the JSON.JsonResource::collection()'s paginationResponse() method to customize the pagination format.$request->user() or auth()->user(). The Resource's toArray() method accepts a Request parameter, allowing you to determine which fields to output based on the user's permissions.$this->when() in Full Resource to dynamically output fields based on context.📖 Summary
- API Resource: Controlling Field Exposure, Formatting, and Nesting Relationships
- use
whenLoaded()for conditional loading of relationships to avoid N+1 lazy loading - Brief Resource is used for nested scenes to avoid circular references
- RESTful APIs follow the URI + HTTP verb + status code specification
- QueryFilter encapsulates filtering and sorting logic, keeping the controller clean
- API versions are indicated by URL prefixes (/v1/, /v2/), and resources can be inherited and reused
📝 Exercises
-
Basic Exercise (⭐): Create three resource classes—ShopResource, ProductResource, and OrderResource—for ShopMetrics. In the controller, replace the direct return of the model to ensure consistent JSON formatting.
-
Advanced Exercise (⭐⭐): Implement the ShopFilter query filter to support the search, status, min_revenue, and sort parameters. Use it in ShopController::index and test the filtering functionality using Postman.
-
Challenge (⭐⭐⭐): Design two API versions, V1 and V2—V1 returns only the basic fields, while V2 additionally returns revenue, products_count, and links. Implement this using resource inheritance, ensuring that the V1 endpoint does not break existing clients.



