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
- •Inertia routes (default for monolith) — defined in
routes/web.php, render Inertia pages viaInertia::render(). The browser receives a full React page. - •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:
| Verb | URI | Action | Route Name |
|---|---|---|---|
| GET | /orders | index | orders.index |
| GET | /orders/create | create | orders.create |
| POST | /orders | store | orders.store |
| GET | /orders/{order} | show | orders.show |
| GET | /orders/{order}/edit | edit | orders.edit |
| PUT/PATCH | /orders/{order} | update | orders.update |
| DELETE | /orders/{order} | destroy | orders.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):
| Verb | URI | Action | Route Name |
|---|---|---|---|
| GET | /api/v1/orders | index | orders.index |
| POST | /api/v1/orders | store | orders.store |
| GET | /api/v1/orders/{order} | show | orders.show |
| PUT/PATCH | /api/v1/orders/{order} | update | orders.update |
| DELETE | /api/v1/orders/{order} | destroy | orders.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
- •Bad:
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
| Method | Purpose | Request Body | Idempotent | Laravel Response Helper |
|---|---|---|---|---|
| GET | Retrieve resource(s) | No | Yes | response()->json($data) |
| POST | Create resource | Yes | No | response()->json($data, 201) |
| PUT | Full update | Yes | Yes | response()->json($data) |
| PATCH | Partial update | Yes | Yes | response()->json($data) |
| DELETE | Remove resource | No | Yes | response()->json(null, 204) |
Status Codes
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE |
| 301 | Moved Permanently | Resource URL changed permanently |
| 302 | Found | Redirect after Inertia form submission |
| 400 | Bad Request | Malformed request syntax |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Resource state conflict (e.g., duplicate) |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Server Error | Unexpected 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]=valuefor filtering - •
sort=fieldfor ascending,sort=-fieldfor 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']);
});