API Resources — Standardizing Every API Response

API Resources — Standardizing Every API Response

Reading Time: 4 minutes

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
  • 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 — passwordremember_tokenstripe_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;

    /**
     * @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()passwordremember_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.
  • 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 | Back to README →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.