Laravel JSON API: API Resources, Eloquent, and REST Responses

Last updated:

Laravel is one of the most productive frameworks for building JSON APIs. Its built-in tooling — Route::apiResource, Eloquent API Resources, Form Request validation, and automatic JSON error responses — handles the boilerplate so you can focus on business logic. This guide covers the complete pipeline from routing to serialization to error handling.

Route::apiResource and Controller Setup

Route::apiResource registers five RESTful routes in a single line and automatically maps them to controller methods. It omits the create and edit routes (HTML form routes) that are irrelevant for JSON APIs.

// routes/api.php
use App\Http\Controllers\Api\ProductController;

Route::apiResource('products', ProductController::class);
// Generates: GET /products, POST /products,
//            GET /products/{product}, PUT/PATCH /products/{product},
//            DELETE /products/{product}

// ProductController.php
namespace App\Http\Controllers\Api;
use App\Http\Resources\ProductResource;
use App\Http\Resources\ProductCollection;
use App\Http\Requests\StoreProductRequest;
use App\Models\Product;

class ProductController extends Controller
{
    public function index()
    {
        $products = Product::paginate(20);
        return new ProductCollection($products);
    }

    public function store(StoreProductRequest $request)
    {
        $product = Product::create($request->validated());
        return new ProductResource($product)
            ->response()
            ->setStatusCode(201);
    }

    public function show(Product $product)  // route model binding
    {
        return new ProductResource($product);
    }

    public function update(StoreProductRequest $request, Product $product)
    {
        $product->update($request->validated());
        return new ProductResource($product);
    }

    public function destroy(Product $product)
    {
        $product->delete();
        return response()->noContent();  // 204 No Content
    }
}

API Resources — JSON Shape Control

API Resources are the recommended way to transform Eloquent models into JSON. They decouple your database schema from your API contract, letting you rename fields, cast types, and conditionally include relationships.

// app/Http/Resources/ProductResource.php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;

class ProductResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'price'      => (float) $this->price,  // cast to float, not string
            'currency'   => 'USD',
            'created_at' => $this->created_at->toISOString(),
            'category'   => new CategoryResource($this->whenLoaded('category')),
            'links'      => [
                'self' => route('products.show', $this->id),
            ],
        ];
    }
}

// ResourceCollection with metadata
class ProductCollection extends ResourceCollection
{
    public function toArray($request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total'        => $this->total(),
                'per_page'     => $this->perPage(),
                'current_page' => $this->currentPage(),
            ],
        ];
    }
}

Request Validation with Form Requests

Form Requests centralize validation logic and authorization into a dedicated class. When validation fails, Laravel automatically returns a 422 JSON response — no boilerplate needed in the controller.

// app/Http/Requests/StoreProductRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;

class StoreProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Product::class);
    }

    public function rules(): array
    {
        return [
            'name'        => ['required', 'string', 'max:200'],
            'price'       => ['required', 'numeric', 'min:0', 'max:99999'],
            'category_id' => ['required', 'exists:categories,id'],
            'description' => ['nullable', 'string', 'max:2000'],
        ];
    }

    public function messages(): array
    {
        return [
            'category_id.exists' => 'The selected category does not exist.',
        ];
    }
}

// Laravel auto-returns 422 with JSON errors if validation fails:
// {"message": "The name field is required.",
//  "errors": {"name": ["The name field is required."]}}

Eloquent Serialization: $casts, $hidden, $appends

Eloquent models control their own serialization via $casts, $hidden, and $appends. These apply whenever the model is converted to an array or JSON — including inside API Resources.

// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    // Auto-cast to PHP types on access
    protected $casts = [
        'price'        => 'decimal:2',
        'is_active'    => 'boolean',
        'metadata'     => 'array',           // JSON column → PHP array
        'published_at' => 'datetime',
    ];

    // Never include in JSON output
    protected $hidden = ['internal_code', 'cost_price'];

    // Always include in JSON output (even if not a DB column)
    protected $appends = ['price_with_tax'];

    public function getPriceWithTaxAttribute(): float
    {
        return (float) $this->price * 1.2;
    }

    // Relationships
    public function category(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Category::class);
    }
}

Handling Relationships in JSON

The key to relationship serialization in Laravel APIs is whenLoaded(): it only includes the relationship in the JSON output if it was already eager-loaded, preventing implicit N+1 queries.

// Eager load to avoid N+1 queries
$products = Product::with(['category', 'tags'])->paginate(20);

// In Resource: only include relationship if loaded
public function toArray($request): array
{
    return [
        'id'          => $this->id,
        'name'        => $this->name,
        'category'    => new CategoryResource($this->whenLoaded('category')),
        // Only serializes if ->with('category') was used — no N+1
        'tags'        => TagResource::collection($this->whenLoaded('tags')),
        'category_id' => $this->category_id,  // always include FK
    ];
}

// Nested route example
Route::apiResource('categories.products', ProductController::class)
    ->shallow();
// → GET /categories/{category}/products
// → GET /products/{product} (shallow — single resource)

JSON Error Responses

Laravel's exception handler can be extended to return structured JSON error responses for any exception type. Use the wantsJson() check to only return JSON when the client expects it.

// Custom exception handler (app/Exceptions/Handler.php)
use Illuminate\Http\JsonResponse;
use Illuminate\Database\Eloquent\ModelNotFoundException;

public function render($request, \Throwable $exception): JsonResponse|\Illuminate\Http\Response
{
    if ($request->wantsJson()) {
        if ($exception instanceof ModelNotFoundException) {
            return response()->json([
                'message' => 'Resource not found',
                'error'   => 'not_found',
            ], 404);
        }

        if ($exception instanceof \Illuminate\Auth\AuthenticationException) {
            return response()->json(['message' => 'Unauthenticated'], 401);
        }
    }
    return parent::render($request, $exception);
}

// Or use the built-in abort() helper
public function show(Product $product)
{
    abort_unless($product->is_active, 404, 'Product not found');
    return new ProductResource($product);
}

Pagination and Filtering

Laravel's paginator automatically adds links and meta to JSON responses. The table below shows the standard fields included in a paginated API Resource response.

FieldValueDescription
dataarrayThe page of resources
links.firstURLFirst page URL
links.lastURLLast page URL
links.prevURL or nullPrevious page URL
links.nextURL or nullNext page URL
meta.current_pageintCurrent page number
meta.last_pageintTotal pages
meta.totalintTotal record count
meta.per_pageintRecords per page
// Filter with query parameters
public function index(Request $request)
{
    $query = Product::query();

    if ($request->filled('category')) {
        $query->where('category_id', $request->category);
    }
    if ($request->filled('search')) {
        $query->where('name', 'like', "%{$request->search}%");
    }
    if ($request->filled('sort')) {
        $query->orderBy($request->sort, $request->get('direction', 'asc'));
    }

    return ProductResource::collection($query->paginate(20));
}

Definitions

API Resource
A Laravel JsonResource class that transforms an Eloquent model instance into an array for JSON output; controls exactly which fields are exposed.
Route Model Binding
Automatic injection of Eloquent model instances based on route parameters; throws 404 if the model doesn't exist.
Form Request
A dedicated request class with rules() and authorize() methods; auto-validates incoming data and returns 422 on failure.
paginate($n)
Eloquent method that returns a LengthAwarePaginator with the requested page of results and metadata (total, per_page, current_page).
whenLoaded($relation)
Resource method that only includes a relationship in the JSON output if it was already eager-loaded, preventing implicit N+1 queries.

FAQ

What is the difference between Route::resource and Route::apiResource in Laravel?

Route::apiResource omits the create and edit routes (HTML form routes not needed for JSON APIs). Route::resource includes all 7 routes including GET /products/create and GET /products/{id}/edit. For JSON-only APIs always use apiResource — it keeps your route list clean and avoids registering routes that would never be called. Both support nested resources: Route::apiResource('posts.comments', CommentController::class) generates routes like POST /posts/{post}/comments.

How do I return a 201 Created response in Laravel API?

Use return new ProductResource($model)->response()->setStatusCode(201) — the ->response() method converts the resource to a JsonResponse object, and ->setStatusCode(201) sets the HTTP status. Alternatively use response()->json($data, 201) for raw arrays. Add a Location header with $response->header('Location', route('products.show', $id)). Avoid returning 200 for POST requests that create a new resource — 201 communicates the outcome clearly to API clients and HTTP intermediaries.

How does Laravel handle JSON request bodies?

Laravel automatically parses application/json request bodies. Access parsed fields with $request->input('key') or $request->all(). $request->json() returns the decoded input bag. $request->validated() returns only the Form Request-declared fields — use this for safety to prevent mass-assignment vulnerabilities. Raw body access is available via $request->getContent(). Laravel also accepts application/x-www-form-urlencoded and multipart/form-data through the same $request->input() interface.

What is a Laravel API Resource?

A JsonResource class that maps Eloquent models to JSON. The toArray() method defines the output shape, preventing over-exposure of internal model fields. ResourceCollection handles arrays and paginators, letting you add top-level metadata. whenLoaded() avoids N+1 queries by only including a relationship if it was already eager-loaded. Generate resources with php artisan make:resource ProductResource.

How do I handle validation errors as JSON in Laravel?

Laravel automatically returns 422 with a JSON errors envelope when a Form Request fails and the request carries Accept: application/json. The errors object maps field names to arrays of messages. Customize per-field messages with the messages() method on the Form Request. For global customization — changing the response envelope — override failedValidation() or handle ValidationException in the application exception Handler.

How do I paginate a JSON API response in Laravel?

Use paginate($n) on any Eloquent query and wrap the result in a ResourceCollection or use ProductResource::collection(). Laravel automatically adds links (first, prev, next, last) and meta (total, current_page, last_page, per_page) to the JSON envelope. Preserve filter query strings across pages with $paginator->appends($request->except('page')). For large tables, cursorPaginate($n) is more efficient than offset-based pagination.

How do I add authentication to a Laravel JSON API?

Use Laravel Sanctum for SPA and mobile token authentication (recommended). Install with composer require laravel/sanctum, add the auth:sanctum middleware to API routes, and issue tokens with $user->createToken('api-token')->plainTextToken. Sanctum also supports cookie-based auth for same-domain SPAs. For OAuth2 flows use Laravel Passport instead. Return 401 for unauthenticated requests by customizing unauthenticated() in the exception Handler.

How do I eager-load relationships in a Laravel JSON API without N+1 queries?

Use Product::with(['category', 'tags'])->paginate(20) to load all relationships in 3 queries instead of 1 + N + N. In the Resource, use $this->whenLoaded('category') so the relationship is only serialized if it was eager-loaded — if not loaded, the field is omitted rather than triggering a lazy load. For scoped eager loading use {"with(['comments' => fn($q) => $q->latest()->take(5)])"}. Call Model::preventLazyLoading() in AppServiceProvider during local development to catch N+1 issues immediately.

Further reading and primary sources

  • Laravel API ResourcesOfficial Laravel docs for JsonResource, ResourceCollection, and conditional attributes
  • Laravel ValidationComplete validation rules reference, Form Requests, and custom error messages
  • Laravel SanctumAPI token authentication and SPA cookie-based authentication for Laravel APIs