npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-reactWant just this skill?
Then install: npx claudepluginhub u/[userId]/[slug]
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.
This skill uses the workspace's default tool permissions.
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 filteringsort=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']);
});
Similar Skills
Activates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.