Laravel API Best Practices: Building Clean, Scalable APIs
You've scaffolded a Laravel app, created a few routes that return Eloquent models directly, and called it an API. It works in Postman. Then a mobile team starts consuming it and immediately asks: "Why does the error format change between endpoints?" "How do we paginate this?" "What happened to the avatar_url field after your last deploy?" Sound familiar?
Building a production-ready REST API demands consistency, predictability, and defensive design. Here's how to get there with Laravel, covering everything from response shaping to authentication scoping.
API Resources and Resource Collections
Raw Eloquent models leak your database schema to consumers. Column renames break mobile apps. Hidden attributes get accidentally exposed. API Resources solve this by providing a stable transformation layer.
<?php
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->value,
'total_cents' => $this->total_cents,
'total_formatted' => number_format($this->total_cents / 100, 2),
'customer' => new UserResource($this->whenLoaded('customer')),
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shipping_address' => new AddressResource($this->whenLoaded('shippingAddress')),
'notes' => $this->when($request->user()?->isAdmin(), $this->internal_notes),
'created_at' => $this->created_at->toIso8601String(),
'shipped_at' => $this->shipped_at?->toIso8601String(),
];
}
}
Key details: whenLoaded prevents N+1 queries by only including relations that were eager-loaded. when conditionally includes fields -- here, internal notes only appear for admins. Money is stored as integers (cents) to avoid floating-point issues, with a formatted version for display convenience.
For collections with pagination metadata:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class OrderCollection extends ResourceCollection
{
public $collects = OrderResource::class;
public function paginationInformation(Request $request, array $paginated, array $default): array
{
return [
'meta' => [
'current_page' => $paginated['current_page'],
'per_page' => $paginated['per_page'],
'total' => $paginated['total'],
'last_page' => $paginated['last_page'],
],
];
}
}
Usage in a controller: return new OrderCollection(Order::with(['customer', 'items'])->paginate(25));. The pagination envelope is standardized across every list endpoint.
Form Request Validation
Validation belongs in Form Request classes, not controllers. This gives you dedicated objects for authorization, custom messages, and complex nested rules.
<?php
namespace App\Http\Requests;
use App\Enums\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:50'],
'items.*.product_id' => ['required', 'integer', Rule::exists('products', 'id')->where('is_active', true)],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
'items.*.options' => ['sometimes', 'array'],
'items.*.options.size' => ['sometimes', 'string', Rule::in(['S', 'M', 'L', 'XL'])],
'shipping_address_id' => [
'required',
Rule::exists('addresses', 'id')->where('user_id', $this->user()->id),
],
'coupon_code' => ['nullable', 'string', 'exists:coupons,code'],
'notes' => ['nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'items.min' => 'An order must contain at least one item.',
'items.max' => 'An order cannot contain more than 50 items.',
'items.*.product_id.exists' => 'Product :input is unavailable or does not exist.',
'shipping_address_id.exists' => 'The selected address does not belong to your account.',
];
}
/** @return array{items: array<array{product_id: int, quantity: int}>, shipping_address_id: int} */
public function validated($key = null, $default = null): mixed
{
return parent::validated($key, $default);
}
}
The authorize method delegates to a policy. The shipping_address_id rule scopes the existence check to the authenticated user's addresses -- a consumer cannot reference another user's address. Custom messages give API consumers actionable error text instead of generic validation strings.
Consistent Error Handling
Every error your API returns should follow a single format. Clients should never have to guess the shape of an error response.
Define a base API exception:
<?php
namespace App\Exceptions;
use Exception;
use Symfony\Component\HttpFoundation\Response;
class ApiException extends Exception
{
public function __construct(
string $message,
protected int $statusCode = Response::HTTP_INTERNAL_SERVER_ERROR,
protected string $errorCode = 'INTERNAL_ERROR',
protected array $details = [],
) {
parent::__construct($message);
}
public function render(): \Illuminate\Http\JsonResponse
{
return response()->json([
'error' => [
'code' => $this->errorCode,
'message' => $this->getMessage(),
'details' => $this->details ?: null,
],
], $this->statusCode);
}
}
Then create specific exceptions:
<?php
namespace App\Exceptions;
use Symfony\Component\HttpFoundation\Response;
class InsufficientStockException extends ApiException
{
public function __construct(int $productId, int $requested, int $available)
{
parent::__construct(
message: "Insufficient stock for product {$productId}.",
statusCode: Response::HTTP_CONFLICT,
errorCode: 'INSUFFICIENT_STOCK',
details: ['product_id' => $productId, 'requested' => $requested, 'available' => $available],
);
}
}
In bootstrap/app.php, catch Laravel's built-in exceptions and normalize them:
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ValidationException $e) {
return response()->json([
'error' => [
'code' => 'VALIDATION_ERROR',
'message' => 'The given data was invalid.',
'details' => $e->errors(),
],
], 422);
});
$exceptions->render(function (NotFoundHttpException $e) {
return response()->json([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'The requested resource was not found.',
'details' => null,
],
], 404);
});
})
Now every error -- validation, not found, business logic -- returns { "error": { "code": "...", "message": "...", "details": ... } }. Clients parse one shape.
API Versioning
Version from day one. URL-based versioning with route prefixes is the most explicit and debuggable approach.
// routes/api.php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->as('v1.')->group(function () {
Route::apiResource('orders', App\Http\Controllers\V1\OrderController::class);
Route::apiResource('products', App\Http\Controllers\V1\ProductController::class);
});
Route::prefix('v2')->as('v2.')->group(function () {
Route::apiResource('orders', App\Http\Controllers\V2\OrderController::class);
Route::apiResource('products', App\Http\Controllers\V2\ProductController::class);
});
Each version gets its own controller namespace. V2 controllers can reuse services and resources from V1 where nothing changed, and diverge where needed:
<?php
namespace App\Http\Controllers\V2;
use App\Http\Controllers\Controller;
use App\Http\Resources\V2\OrderResource;
use App\Models\Order;
use App\Http\Requests\StoreOrderRequest;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function store(StoreOrderRequest $request): JsonResponse
{
$order = app(\App\Services\OrderService::class)->create($request->validated());
return response()->json([
'data' => new OrderResource($order),
], 201);
}
public function index(): \App\Http\Resources\V2\OrderCollection
{
$orders = Order::with(['customer', 'items.product'])
->where('user_id', auth()->id())
->latest()
->paginate(25);
return new \App\Http\Resources\V2\OrderCollection($orders);
}
}
Business logic lives in service classes, shared across versions. Only the HTTP layer (controllers, resources, requests) is versioned. This keeps duplication minimal.
Rate Limiting
The default throttle:60,1 is a blunt instrument. Production APIs need tiered rate limits based on the consumer's plan.
In AppServiceProvider::boot():
<?php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if (! $user) {
return Limit::perMinute(20)->by($request->ip());
}
return match ($user->plan) {
'enterprise' => Limit::perMinute(500)->by($user->id),
'pro' => Limit::perMinute(200)->by($user->id),
default => Limit::perMinute(60)->by($user->id),
};
});
RateLimiter::for('api-heavy', function (Request $request) {
return Limit::perMinute(10)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'error' => [
'code' => 'RATE_LIMIT_EXCEEDED',
'message' => 'Too many requests. Please retry after the period indicated in the Retry-After header.',
'details' => null,
],
], 429, $headers);
});
});
}
Apply api-heavy to expensive endpoints like report generation or bulk exports. The custom response callback keeps the error format consistent with the global exception handler. The Retry-After header is automatically included by Laravel.
Authentication with Sanctum
Sanctum handles both SPA session auth and token-based API auth. For API consumers, token abilities (scopes) control what each token can do.
Issuing tokens with specific abilities:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CreateTokenRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
class AuthTokenController extends Controller
{
public function store(CreateTokenRequest $request): JsonResponse
{
$user = User::where('email', $request->validated('email'))->first();
if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
return response()->json([
'error' => [
'code' => 'INVALID_CREDENTIALS',
'message' => 'The provided credentials are incorrect.',
'details' => null,
],
], 401);
}
$abilities = $request->validated('abilities', ['read']);
$token = $user->createToken(
name: $request->validated('device_name', 'api-token'),
abilities: $abilities,
expiresAt: now()->addDays(30),
);
return response()->json([
'data' => [
'token' => $token->plainTextToken,
'abilities' => $abilities,
'expires_at' => $token->accessToken->expires_at->toIso8601String(),
],
], 201);
}
}
Checking abilities in controllers or middleware:
// In a controller method
public function destroy(Order $order): JsonResponse
{
if (! $request->user()->tokenCan('orders:delete')) {
abort(403, 'This token does not have permission to delete orders.');
}
$order->delete();
return response()->json(null, 204);
}
You can also use Sanctum's CheckAbilities or CheckForAnyAbility middleware on route groups for cleaner enforcement. Define granular abilities like orders:read, orders:write, orders:delete rather than broad admin scopes -- it gives API consumers the principle of least privilege.
Response Macros for Consistent JSON Structure
Define response macros so every controller uses the same envelope without manual repetition.
In AppServiceProvider::boot():
use Illuminate\Support\Facades\Response;
Response::macro('success', function (mixed $data = null, int $status = 200, array $headers = []) {
return response()->json([
'data' => $data,
], $status, $headers);
});
Response::macro('created', function (mixed $data = null, array $headers = []) {
return response()->json([
'data' => $data,
], 201, $headers);
});
Response::macro('noContent', function () {
return response()->json(null, 204);
});
Controller usage becomes terse and uniform:
public function show(Order $order): JsonResponse
{
return response()->success(new OrderResource($order->load('items')));
}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->orderService->create($request->validated());
return response()->created(new OrderResource($order));
}
public function destroy(Order $order): JsonResponse
{
$order->delete();
return response()->noContent();
}
Every success response wraps data under a data key. Every error wraps under an error key. Clients can rely on this unconditionally.
Query Filtering and Sorting
API consumers need to filter and sort list endpoints. You can use spatie/laravel-query-builder for a declarative approach, or build a lightweight manual solution.
Using Spatie's package:
<?php
namespace App\Http\Controllers\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\OrderCollection;
use App\Models\Order;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedSort;
use Spatie\QueryBuilder\QueryBuilder;
class OrderController extends Controller
{
public function index(): OrderCollection
{
$orders = QueryBuilder::for(Order::class)
->allowedFilters([
AllowedFilter::exact('status'),
AllowedFilter::scope('created_between'),
AllowedFilter::exact('customer.id', 'customer_id'),
AllowedFilter::partial('order_number'),
])
->allowedSorts([
AllowedSort::field('created', 'created_at'),
AllowedSort::field('total', 'total_cents'),
'order_number',
])
->allowedIncludes(['customer', 'items', 'items.product'])
->defaultSort('-created_at')
->where('user_id', auth()->id())
->paginate(request()->integer('per_page', 25));
return new OrderCollection($orders);
}
}
Consumers query with: GET /api/v1/orders?filter[status]=shipped&sort=-total&include=items.product&per_page=10. The package rejects any filters or sorts not explicitly allowed, preventing SQL injection via column name manipulation.
For a manual approach without the dependency:
public function index(Request $request): OrderCollection
{
$query = Order::query()
->where('user_id', $request->user()->id)
->with(['items']);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('from')) {
$query->where('created_at', '>=', $request->date('from'));
}
$sortField = match ($request->input('sort')) {
'total' => 'total_cents',
'created' => 'created_at',
default => 'created_at',
};
$sortDir = $request->input('direction', 'desc') === 'asc' ? 'asc' : 'desc';
$orders = $query->orderBy($sortField, $sortDir)
->paginate($request->integer('per_page', 25));
return new OrderCollection($orders);
}
The match expression whitelists sortable columns. Never pass raw user input to orderBy.
Takeaways
- Always use API Resources. They decouple your database schema from your API contract. Use
whenLoadedandwhenfor conditional data. - Form Requests handle validation, authorization, and custom messages in one place. Scope existence checks to the authenticated user.
- Standardize every error response with a global exception handler. One shape:
{ error: { code, message, details } }. - Version from day one with URL prefixes. Keep business logic in shared services; only version the HTTP layer.
- Tier your rate limits by user plan. Use custom responses to maintain your error format.
- Scope Sanctum tokens with granular abilities. Set expiration dates. Never issue tokens with more access than needed.
- Response macros eliminate boilerplate and enforce a consistent envelope.
- Whitelist all filterable and sortable fields. Never pass raw input to query builders.
Every decision above serves one goal: make your API predictable. Consumers should never wonder what shape a response will take, how errors are reported, or whether a field will disappear after your next deploy. Consistency is the feature.