Skill
Community

api-design

Install
1
Install the plugin
$
npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-react

Want just this skill?

Then install: npx claudepluginhub u/[userId]/[slug]

Description

Design RESTful APIs with Laravel following best practices for resource naming, HTTP methods, status codes, pagination, filtering, error responses, and versioning. Use when creating API endpoints, designing response formats, implementing pagination or filtering, handling API errors, or versioning your API. Also covers Inertia route conventions for monolith apps. Triggers on API endpoint, REST, route design, pagination, filtering, error response, or CORS.

Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

API Design with Laravel

Laravel supports two distinct routing strategies depending on the application architecture. Choose the right one based on your delivery target.

Route Conventions

Two Modes of Operation

  1. Inertia routes (default for monolith) — defined in routes/web.php, render Inertia pages via Inertia::render(). The browser receives a full React page.
  2. API routes (for mobile / external clients) — defined in routes/api.php, return pure JSON. Authenticated via Sanctum tokens.

Both can coexist in the same application. Inertia controllers return Inertia::render() while API controllers return response()->json().


Inertia Route Patterns

Basic Resource Routes

// routes/web.php
use App\Http\Controllers\OrderController;

Route::middleware(['auth', 'verified'])->group(function () {
    Route::resource('orders', OrderController::class);
});

The Route::resource() macro generates all seven RESTful routes:

VerbURIActionRoute Name
GET/ordersindexorders.index
GET/orders/createcreateorders.create
POST/ordersstoreorders.store
GET/orders/{order}showorders.show
GET/orders/{order}/editeditorders.edit
PUT/PATCH/orders/{order}updateorders.update
DELETE/orders/{order}destroyorders.destroy

Inertia Controller Example

namespace App\Http\Controllers;

use App\Models\Order;
use App\Http\Requests\StoreOrderRequest;
use App\Http\Requests\UpdateOrderRequest;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class OrderController extends Controller
{
    public function index(): Response
    {
        return Inertia::render('Orders/Index', [
            'orders' => Order::query()
                ->where('user_id', auth()->id())
                ->with('customer:id,name')
                ->latest()
                ->paginate(25),
        ]);
    }

    public function create(): Response
    {
        return Inertia::render('Orders/Create', [
            'customers' => Customer::select('id', 'name')->get(),
        ]);
    }

    public function store(StoreOrderRequest $request): RedirectResponse
    {
        $order = Order::create($request->validated());

        return redirect()
            ->route('orders.show', $order)
            ->with('success', 'Order created successfully.');
    }

    public function show(Order $order): Response
    {
        $this->authorize('view', $order);

        return Inertia::render('Orders/Show', [
            'order' => $order->load('items.product', 'customer'),
        ]);
    }

    public function edit(Order $order): Response
    {
        $this->authorize('update', $order);

        return Inertia::render('Orders/Edit', [
            'order' => $order->load('items'),
            'customers' => Customer::select('id', 'name')->get(),
        ]);
    }

    public function update(UpdateOrderRequest $request, Order $order): RedirectResponse
    {
        $order->update($request->validated());

        return redirect()
            ->route('orders.show', $order)
            ->with('success', 'Order updated successfully.');
    }

    public function destroy(Order $order): RedirectResponse
    {
        $this->authorize('delete', $order);
        $order->delete();

        return redirect()
            ->route('orders.index')
            ->with('success', 'Order deleted successfully.');
    }
}

Nested Resources

Route::resource('orders.items', OrderItemController::class)->scoped();

Generates routes like /orders/{order}/items/{item}. The scoped() call enforces that the item belongs to the given order via implicit model binding.

Partial Resources

// Only specific actions
Route::resource('orders', OrderController::class)->only(['index', 'show']);

// Everything except specific actions
Route::resource('orders', OrderController::class)->except(['destroy']);

Route Naming Conventions

  • Use plural nouns for resources: orders, users, products
  • Nested names follow dot notation: orders.items.index
  • Custom action routes use verbs: orders.cancel, orders.export

API Route Patterns

Basic API Resource Routes

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

Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
    Route::apiResource('orders', OrderController::class);
    Route::post('orders/{order}/cancel', [OrderController::class, 'cancel']);
});

Route::apiResource() generates five routes (excludes create and edit since APIs do not serve HTML forms):

VerbURIActionRoute Name
GET/api/v1/ordersindexorders.index
POST/api/v1/ordersstoreorders.store
GET/api/v1/orders/{order}showorders.show
PUT/PATCH/api/v1/orders/{order}updateorders.update
DELETE/api/v1/orders/{order}destroyorders.destroy

API Controller Example

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreOrderRequest;
use App\Http\Requests\UpdateOrderRequest;
use App\Http\Resources\OrderResource;
use App\Http\Resources\OrderCollection;
use App\Models\Order;
use Illuminate\Http\JsonResponse;

class OrderController extends Controller
{
    public function index(): OrderCollection
    {
        $orders = Order::query()
            ->where('user_id', auth()->id())
            ->with('customer:id,name')
            ->latest()
            ->paginate(25);

        return new OrderCollection($orders);
    }

    public function store(StoreOrderRequest $request): JsonResponse
    {
        $order = Order::create($request->validated());

        return (new OrderResource($order))
            ->response()
            ->setStatusCode(201);
    }

    public function show(Order $order): OrderResource
    {
        $this->authorize('view', $order);

        return new OrderResource($order->load('items.product', 'customer'));
    }

    public function update(UpdateOrderRequest $request, Order $order): OrderResource
    {
        $this->authorize('update', $order);
        $order->update($request->validated());

        return new OrderResource($order->fresh());
    }

    public function destroy(Order $order): JsonResponse
    {
        $this->authorize('delete', $order);
        $order->delete();

        return response()->json(null, 204);
    }

    public function cancel(Order $order): OrderResource
    {
        $this->authorize('cancel', $order);
        $order->markAsCancelled();

        return new OrderResource($order->fresh());
    }
}

RESTful Resource Naming

URL Structure Rules

  • Use plural nouns for collections: /orders, /users, /products
  • Use IDs for individual resources: /orders/{order}
  • Nest only one level deep: /orders/{order}/items
  • Use POST with verbs for non-CRUD actions: /orders/{order}/cancel
  • Avoid deeply nested routes; use query parameters instead:
    • Bad: /users/{user}/orders/{order}/items/{item}/reviews
    • Good: /reviews?order_item_id=42

Common Patterns

// Standard CRUD
Route::apiResource('products', ProductController::class);

// Nested resource (one level)
Route::apiResource('orders.items', OrderItemController::class)->scoped();

// Non-CRUD actions (use POST for state changes, GET for queries)
Route::post('orders/{order}/cancel', [OrderController::class, 'cancel']);
Route::post('orders/{order}/ship', [OrderController::class, 'ship']);
Route::get('orders/{order}/invoice', [OrderController::class, 'invoice']);

// Singleton resource (e.g., authenticated user's profile)
Route::apiSingleton('profile', ProfileController::class);

HTTP Methods & Status Codes

Complete Reference Table

MethodPurposeRequest BodyIdempotentLaravel Response Helper
GETRetrieve resource(s)NoYesresponse()->json($data)
POSTCreate resourceYesNoresponse()->json($data, 201)
PUTFull updateYesYesresponse()->json($data)
PATCHPartial updateYesYesresponse()->json($data)
DELETERemove resourceNoYesresponse()->json(null, 204)

Status Codes

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE
301Moved PermanentlyResource URL changed permanently
302FoundRedirect after Inertia form submission
400Bad RequestMalformed request syntax
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not authorized
404Not FoundResource does not exist
409ConflictResource state conflict (e.g., duplicate)
422Unprocessable EntityValidation errors
429Too Many RequestsRate limit exceeded
500Server ErrorUnexpected server failure

Request/Response Format

JSON Response Structure (Flat Style — Recommended)

Single resource:

{
  "data": {
    "id": 1,
    "order_number": "ORD-20240101-001",
    "status": "pending",
    "total": "149.99",
    "customer": {
      "id": 5,
      "name": "Jane Doe"
    },
    "created_at": "2024-01-15T10:30:00Z"
  }
}

Collection with pagination:

{
  "data": [
    { "id": 1, "order_number": "ORD-001", "status": "pending" },
    { "id": 2, "order_number": "ORD-002", "status": "shipped" }
  ],
  "links": {
    "first": "/api/v1/orders?page=1",
    "last": "/api/v1/orders?page=5",
    "prev": null,
    "next": "/api/v1/orders?page=2"
  },
  "meta": {
    "current_page": 1,
    "last_page": 5,
    "per_page": 25,
    "total": 120
  }
}

API Resource Classes

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'order_number' => $this->order_number,
            'status' => $this->status,
            'total' => $this->total,
            'customer' => new CustomerResource($this->whenLoaded('customer')),
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
            'items_count' => $this->whenCounted('items'),
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
        ];
    }
}

Pagination

Offset Pagination (Standard)

// Controller
$orders = Order::query()->latest()->paginate(25);

// Response includes links and meta automatically when using API Resources
return new OrderCollection($orders);

Best for: admin dashboards, traditional page-based navigation, when total count matters.

Cursor Pagination (High Performance)

$orders = Order::query()->latest()->cursorPaginate(25);

return new OrderCollection($orders);

Best for: infinite scroll, real-time feeds, very large datasets. Cursor pagination does not count total rows, making it significantly faster on large tables.

Customizing Per-Page

// Allow client to request per-page size (capped)
$perPage = min($request->integer('per_page', 25), 100);
$orders = Order::paginate($perPage);

Filtering & Sorting

Query Parameter Conventions

GET /api/v1/orders?filter[status]=pending&filter[customer_id]=5&sort=-created_at&per_page=25
  • filter[field]=value for filtering
  • sort=field for ascending, sort=-field for descending
  • Multiple sort fields: sort=-created_at,order_number

Manual Filter Implementation

public function index(Request $request): OrderCollection
{
    $query = Order::query()->where('user_id', auth()->id());

    // Apply filters
    if ($status = $request->input('filter.status')) {
        $query->where('status', $status);
    }

    if ($customerId = $request->input('filter.customer_id')) {
        $query->where('customer_id', $customerId);
    }

    if ($search = $request->input('filter.search')) {
        $query->where('order_number', 'like', "%{$search}%");
    }

    // Apply sorting
    $sortField = ltrim($request->input('sort', '-created_at'), '-');
    $sortDirection = str_starts_with($request->input('sort', '-created_at'), '-') ? 'desc' : 'asc';

    $allowedSorts = ['created_at', 'total', 'status', 'order_number'];
    if (in_array($sortField, $allowedSorts)) {
        $query->orderBy($sortField, $sortDirection);
    }

    return new OrderCollection($query->paginate(25));
}

Spatie QueryBuilder Integration

use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;

public function index(): OrderCollection
{
    $orders = QueryBuilder::for(Order::class)
        ->allowedFilters([
            AllowedFilter::exact('status'),
            AllowedFilter::exact('customer_id'),
            AllowedFilter::scope('date_range'),
            AllowedFilter::partial('order_number'),
        ])
        ->allowedSorts(['created_at', 'total', 'order_number'])
        ->allowedIncludes(['customer', 'items'])
        ->defaultSort('-created_at')
        ->paginate(25);

    return new OrderCollection($orders);
}

Error Response Format

Standard Laravel Validation Error (422)

{
  "message": "The given data was invalid.",
  "errors": {
    "email": [
      "The email field is required.",
      "The email must be a valid email address."
    ],
    "name": [
      "The name field is required."
    ]
  }
}

Not Found Error (404)

// Automatic via route model binding (returns 404 if not found)
public function show(Order $order): OrderResource { ... }

// Manual
abort(404, 'Order not found.');

Response:

{
  "message": "Order not found."
}

Unauthorized (401) vs Forbidden (403)

  • 401 Unauthorized: The client has not authenticated. Sanctum returns this when no valid token/session is provided.
  • 403 Forbidden: The client is authenticated but does not have permission. Return this from policy checks.
// 403 via policy
$this->authorize('update', $order);
// If user cannot update, Laravel throws AuthorizationException -> 403

// Manual 403
abort(403, 'You do not own this order.');

Custom Exception Handler

// bootstrap/app.php (Laravel 11+)
use Illuminate\Foundation\Configuration\Exceptions;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (NotFoundHttpException $e) {
        if (request()->expectsJson()) {
            return response()->json([
                'message' => 'Resource not found.',
            ], 404);
        }
    });
})

API Versioning

URL Prefix Versioning (Recommended)

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('orders', Api\V1\OrderController::class);
});

Route::prefix('v2')->group(function () {
    Route::apiResource('orders', Api\V2\OrderController::class);
});

Directory structure:

app/Http/Controllers/Api/
  V1/
    OrderController.php
  V2/
    OrderController.php
app/Http/Resources/
  V1/
    OrderResource.php
  V2/
    OrderResource.php

When to Version

  • Breaking changes to response structure
  • Removing fields from responses
  • Changing field types or semantics
  • Do NOT version for additive changes (new optional fields)

Rate Limiting

Default Throttle Middleware

// routes/api.php
Route::middleware('throttle:api')->group(function () {
    Route::apiResource('orders', OrderController::class);
});

Custom Rate Limiters

// bootstrap/app.php (Laravel 11+)
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

->withMiddleware(function (Middleware $middleware) {
    // ...
})
->booted(function () {
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });

    RateLimiter::for('uploads', function (Request $request) {
        return Limit::perMinute(10)->by($request->user()->id);
    });
})
// Apply to specific routes
Route::post('uploads', [UploadController::class, 'store'])
    ->middleware('throttle:uploads');

Rate limit headers are automatically included: X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After.


CORS Configuration

SPA Mode (API consumed by a separate frontend domain)

// config/cors.php
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 0,
    'supports_credentials' => true,
];

Inertia Monolith

CORS configuration is not needed for Inertia applications because the frontend and backend share the same origin. All requests are same-origin by definition.


Authentication

Sanctum SPA Authentication (Cookie-Based, for Inertia)

This is the default for Laravel + Inertia. Session-based auth with CSRF protection.

// routes/web.php — protected by session auth
Route::middleware(['auth', 'verified'])->group(function () {
    Route::resource('orders', OrderController::class);
});

Login flow is handled by Laravel Breeze or Fortify. Inertia requests include the session cookie automatically.

Sanctum Token Authentication (For Mobile / External API)

// Issue a token
public function login(Request $request): JsonResponse
{
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required|string',
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    $token = $user->createToken($request->device_name)->plainTextToken;

    return response()->json([
        'token' => $token,
        'user' => new UserResource($user),
    ]);
}

// Revoke current token
public function logout(Request $request): JsonResponse
{
    $request->user()->currentAccessToken()->delete();

    return response()->json(null, 204);
}

Client sends the token in every request:

Authorization: Bearer 1|abc123tokenvalue

Authentication Flow Summary

Inertia (Monolith):
  Browser -> POST /login (session cookie set) -> All subsequent requests carry cookie

API (Mobile/SPA):
  Client -> POST /api/login (receives token) -> All subsequent requests carry Bearer token

Protecting Routes

// Inertia routes (session guard, default)
Route::middleware('auth')->group(function () { ... });

// API routes (sanctum token guard)
Route::middleware('auth:sanctum')->group(function () { ... });

// Optional: scope token abilities
Route::middleware(['auth:sanctum', 'ability:orders:read'])->group(function () {
    Route::get('orders', [OrderController::class, 'index']);
});
Stats
Stars0
Forks0
Last CommitFeb 8, 2026

Similar Skills