API Resources — Standardizing Every API Response
Series: Every Laravel Project Should Have These Building Blocks
Part: 33 of 35 Level: Beginner–Intermediate Prerequisites: Thin Controllers
What You’ll Learn
- Why returning raw Eloquent models from APIs is a mistake
- The standard response envelope:
{"success": bool, "message": string, "data": {}} - Building a
BaseApiResourcefor consistent structure - Resource Collections and pagination
- Nested resources and relationships
- A
ApiResponsehelper for non-resource responses
The Problem with Raw Eloquent Models
This is in every beginner’s codebase:
// ❌ Never do this
public function show(Order $order): JsonResponse
{
return response()->json($order);
}
What’s wrong with it?
- Exposes your database schema — column names become part of your API contract. Rename a column, break all clients.
- Leaks sensitive data —
password,remember_token,stripe_customer_idare all included unless you explicitly hide them. - No version control — you can’t add a new field to the model without it appearing in the API.
- Inconsistent shape — different endpoints return different structures with no standard envelope.
API Resources solve all four problems.
The Standard Envelope
Every API response in these projects follows this shape:
{
"success": true,
"message": "Order retrieved successfully.",
"data": {
"id": 123,
"status": "pending",
"total": "$250.00"
}
}
For lists:
{
"success": true,
"message": "Orders retrieved successfully.",
"data": [...],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 15,
"total": 74
}
}
For errors:
{
"success": false,
"message": "Order not found.",
"data": null
}
The BaseApiResource
A base class that enforces the envelope on every resource:
// app/Http/Resources/BaseApiResource.php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class BaseApiResource extends JsonResource
{
/**
* The success message for this resource.
*/
protected string $message = 'Data retrieved successfully.';
/**
* Subclasses define the actual resource fields here.
*
* @return array<string, mixed>
*/
abstract protected function fields(Request $request): array;
/**
* @return array<string, mixed>
*/
final public function toArray(Request $request): array
{
return [
'success' => true,
'message' => $this->message,
'data' => $this->fields($request),
];
}
}
A concrete resource:
// app/Http/Resources/OrderResource.php
final class OrderResource extends BaseApiResource
{
protected string $message = 'Order retrieved successfully.';
protected function fields(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'total' => $this->total_formatted,
'item_count' => $this->items_count ?? $this->items->count(),
'created_at' => $this->created_at->toISOString(),
'customer' => [
'name' => $this->customer_name,
'email' => $this->email,
],
];
}
}
Notice what’s NOT in fields(): password, remember_token, raw integer totals — only exactly what the API consumer needs.
Using Resources in Controllers
public function show(Order $order): OrderResource
{
$this->authorize('view', $order);
$order->loadMissing(['items', 'user']);
return new OrderResource($order);
}
public function store(StoreOrderRequest $request): OrderResource
{
$order = $this->createOrder->execute(
$request->user(),
OrderCustomerData::fromArray($request->validated())
);
return (new OrderResource($order))
->response()
->setStatusCode(201);
}
Resource Collections with Pagination
For list endpoints:
// app/Http/Resources/OrderCollection.php
final class OrderCollection extends ResourceCollection
{
public string $collects = OrderResource::class;
public function toArray(Request $request): array
{
return [
'success' => true,
'message' => 'Orders retrieved successfully.',
'data' => $this->collection,
'meta' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
],
];
}
}
In the controller:
public function index(Request $request): OrderCollection
{
$orders = Order::with(['items'])
->forUser($request->user())
->latest()
->paginate(15);
return new OrderCollection($orders);
}
Nested Resources — Relationships Inside Resources
When a resource includes a related model, use the related resource:
// app/Http/Resources/OrderResource.php
protected function fields(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status->value,
'total' => $this->total_formatted,
// Only include when the relationship is loaded
'items' => OrderItemResource::collection(
$this->whenLoaded('items')
),
'customer' => new UserResource(
$this->whenLoaded('user')
),
];
}
$this->whenLoaded('items') only includes the relationship if it was eager loaded. This prevents accidental N+1 from API consumers who don’t load relationships.
The ApiResponse Helper for Non-Resource Responses
For responses that don’t involve a model (confirmation messages, action results):
// app/Http/Responses/ApiResponse.php
final class ApiResponse
{
public static function success(string $message, mixed $data = null, int $status = 200): JsonResponse
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
], $status);
}
public static function error(string $message, mixed $data = null, int $status = 422): JsonResponse
{
return response()->json([
'success' => false,
'message' => $message,
'data' => $data,
], $status);
}
public static function notFound(string $message = 'Resource not found.'): JsonResponse
{
return self::error($message, status: 404);
}
public static function unauthorized(string $message = 'Unauthorized.'): JsonResponse
{
return self::error($message, status: 403);
}
}
Usage:
public function destroy(Order $order): JsonResponse
{
$this->authorize('delete', $order);
$order->delete();
return ApiResponse::success('Order deleted successfully.');
}
Key Takeaways
- Never return raw Eloquent models from APIs — use Resources to control exactly what’s exposed.
- The standard envelope:
{"success": bool, "message": string, "data": {}}. Every endpoint, every response. BaseApiResourceenforces the envelope automatically. Subclasses only definefields().$this->whenLoaded('relation')prevents N+1 in nested resources.OrderCollection extends ResourceCollectionaddsmetapagination data to list responses.ApiResponsehelper for confirmation messages and non-model responses.
Tips and Gotchas
⚠️ Warning:
$this->whenLoaded('items')returnsMissingValuewhen the relationship isn’t loaded — notnull, not an empty array. This is intentional: the key is omitted from the response entirely. If your API clients expect the key to always be present, use$this->items ?? []instead, with eager loading enforced at the controller.
💡 Tip: Use Resource return types on controller methods (
public function show(Order $order): OrderResource) instead ofJsonResponse. The return type documents what the endpoint returns, and static analysis tools can verify the shape matches the Resource definition.
🔥 Expert Note: Never add conditional logic that changes the response structure based on the authenticated user inside a Resource. A Resource with “if admin, show extra fields” logic is untestable and produces inconsistent API contracts. Use separate Resources (
AdminOrderResource extends OrderResource) for different permission levels.
Further Reading
- Laravel Docs: Eloquent Resources
- Laravel Docs: Resource Collections
- Laravel Docs:
whenLoaded() - JSON:API Specification — a standard envelope format worth knowing even if you don’t adopt it fully