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 BaseApiResource for consistent structure
  • Resource Collections and pagination
  • Nested resources and relationships
  • A ApiResponse helper 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?

  1. Exposes your database schema – column names become part of your API contract. Rename a column, break all clients.
  2. Leaks sensitive data – password, remember_token, stripe_customer_id are all included unless you explicitly hide them.
  3. No version control – you can’t add a new field to the model without it appearing in the API.
  4. 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; /** * The bare fields, without the envelope (used by collections). * * @return array<string, mixed> */ final public function fieldsFor(Request $request): array { return $this->fields($request); } /** * @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 { Gate::authorize('view', $order); // $this->authorize() needs AuthorizesRequests on Laravel 11+ $order->loadMissing(['items', 'user']); return new OrderResource($order); } public function store(StoreOrderRequest $request): JsonResponse { $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 { // Untyped on purpose: ResourceCollection declares `public $collects;`, // and redeclaring it as `public string` is a fatal error. public $collects = OrderResource::class; public function toArray(Request $request): array { return [ 'success' => true, 'message' => 'Orders retrieved successfully.', // Bare fields per item: OrderResource::toArray() would wrap // every row in its own success/message envelope 'data' => $this->collection->map( fn (OrderResource $order): array => $order->fieldsFor($request) ), // `meta` and `links` are added automatically for paginated results ]; } } 

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 = 'Unauthenticated.'): JsonResponse { return self::error($message, status: 401); } public static function forbidden(string $message = 'Forbidden.'): JsonResponse { return self::error($message, status: 403); } } 

Usage:

public function destroy(Order $order): JsonResponse { Gate::authorize('delete', $order); $order->delete(); return ApiResponse::success('Order deleted successfully.'); } 

FAQs

Why use API Resources instead of returning models?

Raw models leak columns you add later and break your contract; a Resource is an explicit whitelist and a stable envelope.

How do I add pagination meta to Laravel API responses?

Return a ResourceCollection – pagination meta (links, total, per_page) is added automatically.

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.
  • BaseApiResource enforces the envelope automatically. Subclasses only define fields().
  • $this->whenLoaded('relation') prevents N+1 in nested resources.
  • OrderCollection extends ResourceCollection adds meta pagination data to list responses.
  • ApiResponse helper for confirmation messages and non-model responses.

Tips and Gotchas

⚠️ Warning: $this->whenLoaded('items') returns MissingValue when the relationship isn’t loaded – not null, 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 of JsonResponse. 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

← Testing Strategy | Laravel Pipeline Pattern →