From laravel-react
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/laravel-react:api-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Laravel supports two distinct routing strategies depending on the application architecture.
Laravel supports two distinct routing strategies depending on the application architecture. Choose the right one based on your delivery target.
routes/web.php, render Inertia
pages via Inertia::render(). The browser receives a full React page.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().
// 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 |
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.');
}
}
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.
// Only specific actions
Route::resource('orders', OrderController::class)->only(['index', 'show']);
// Everything except specific actions
Route::resource('orders', OrderController::class)->except(['destroy']);
orders, users, productsorders.items.indexorders.cancel, orders.export// 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 |
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());
}
}
/orders, /users, /products/orders/{order}/orders/{order}/items/orders/{order}/cancel/users/{user}/orders/{order}/items/{item}/reviews/reviews?order_item_id=42// 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);
| 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) |
| 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 |
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
}
}
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(),
];
}
}
// 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.
$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.
// Allow client to request per-page size (capped)
$perPage = min($request->integer('per_page', 25), 100);
$orders = Order::paginate($perPage);
GET /api/v1/orders?filter[status]=pending&filter[customer_id]=5&sort=-created_at&per_page=25
filter[field]=value for filteringsort=field for ascending, sort=-field for descendingsort=-created_at,order_numberpublic 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));
}
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);
}
{
"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."
]
}
}
// 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."
}
// 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.');
// 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);
}
});
})
// 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
// routes/api.php
Route::middleware('throttle:api')->group(function () {
Route::apiResource('orders', OrderController::class);
});
// 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.
// 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,
];
CORS configuration is not needed for Inertia applications because the frontend and backend share the same origin. All requests are same-origin by definition.
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.
// 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
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
// 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']);
});
npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-reactBuild RESTful APIs with Laravel using API Resources, Sanctum authentication, rate limiting, and versioning. Use when creating API endpoints, transforming responses, or handling API authentication.
Provides REST API design patterns for resource naming, URL structures, HTTP methods/status codes, pagination, filtering, errors, versioning, and rate limiting.
Establishes REST API design patterns for resource naming, HTTP methods and status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs.