Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take QuizAn API is a contract between your application and its consumers. Whether those consumers are a mobile app, a JavaScript frontend, or a third-party integration, they depend on the API being predictable and consistent. Break that contract — change a field name, alter an error format, return unexpected data — and every consumer breaks with it.
Clean API design in Laravel means never exposing your database structure directly, giving consumers flexibility within strict boundaries, and ensuring that every response — success or error — follows the same predictable shape.
API Resources
Returning Eloquent models directly from API endpoints is tempting but dangerous. Models expose your database structure — column names, hidden fields, timestamps, relationship loading — to the outside world. Rename a database column and every consumer breaks. Add an internal field and it leaks into every response. Your database schema becomes your API contract, and database schemas change far more often than API contracts should.
An API Resource solves this by acting as a transformation layer between your Eloquent models and the JSON your API returns. It defines exactly which fields to expose, how to format them, and which relationships to include — so your API contract stays stable even when your database schema changes.
Generate one with Artisan:
php artisan make:resource OrderResource
This creates a class in app/Http/Resources/ with a single toArray() method where you define the response shape:
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,
'status' => $this->status->value,
'subtotal' => $this->subtotal,
'tax' => $this->tax,
'total' => $this->total,
'item_count' => $this->items->count(),
'placed_at' => $this->created_at->toIso8601String(),
'customer' => new UserResource($this->whenLoaded('user')),
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'shipping_address' => new AddressResource($this->whenLoaded('shippingAddress')),
];
}
}
Key patterns to notice:
whenLoaded()— only includes relationships that were eager-loaded, preventing N+1 queries- Explicit field selection — only the fields the consumer needs, not the entire model
- Formatted dates — ISO 8601 strings, not Carbon objects
- Nested resources —
UserResource,OrderItemResourcefor consistent formatting at every level
Resource Collections
Returning an entire table's worth of data in a single response is a performance and security risk — consumers receive thousands of records they never asked for, and your server pays the cost of loading them all. Always paginate list endpoints, and use resource collections to format each item consistently:
// In the controller
public function index(Request $request): AnonymousResourceCollection
{
$orders = Order::where('user_id', $request->user()->id)
->with(['items.product', 'shippingAddress'])
->latest()
->paginate(15);
return OrderResource::collection($orders);
}
Laravel automatically wraps the collection in a data key and includes pagination metadata:
{
"data": [
{ "id": 1, "status": "shipped", "total": 99.99 },
{ "id": 2, "status": "pending", "total": 45.50 }
],
"links": {
"first": "https://example.com/api/orders?page=1",
"last": "https://example.com/api/orders?page=5",
"next": "https://example.com/api/orders?page=2"
},
"meta": {
"current_page": 1,
"per_page": 15,
"total": 73
}
}
Flexible Filtering and Sorting
API consumers need flexibility. A mobile app wants orders sorted by date. A dashboard wants them filtered by status. An admin panel needs both, plus a search on customer name. Handling this manually means a growing chain of conditionals in your controller:
// This gets out of hand fast
$query = Order::query();
if ($request->has('status')) {
$query->where('status', $request->input('status'));
}
if ($request->has('sort')) {
$query->orderBy($request->input('sort'), $request->input('direction', 'asc'));
}
if ($request->has('include') && $request->input('include') === 'items') {
$query->with('items');
}
// And on and on for every parameter...
Every new filter or sort option adds another block. Every parameter needs manual validation. And nothing stops a consumer from sorting by a column you did not intend to expose — which is both a data leak and a potential security risk.
spatie/laravel-query-builder solves this with a declarative approach. You list what is allowed — filters, sorts, includes — and the package handles the query string parsing, validation, and query building:
composer require spatie/laravel-query-builder
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedSort;
class OrderController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$orders = QueryBuilder::for(Order::class)
->allowedFilters([
AllowedFilter::exact('status'),
AllowedFilter::scope('placed_between'),
AllowedFilter::exact('user_id'),
AllowedFilter::partial('customer_name', 'user.name'),
])
->allowedSorts([
AllowedSort::field('total'),
AllowedSort::field('placed_at', 'created_at'),
AllowedSort::field('status'),
])
->allowedIncludes([
'items',
'items.product',
'user',
'shippingAddress',
])
->defaultSort('-created_at')
->where('user_id', $request->user()->id)
->paginate(15);
return OrderResource::collection($orders);
}
}
Now consumers can make requests like:
GET /api/orders?filter[status]=shipped&sort=-total&include=items.product
GET /api/orders?filter[placed_between]=2025-01-01,2025-12-31&sort=placed_at
GET /api/orders?filter[customer_name]=John&include=user
The beauty is that consumers get flexibility, but you control exactly which filters, sorts, and includes are allowed. Unauthorized parameters are silently ignored.
Custom Filters
The built-in filter types — exact(), partial(), scope() — cover most cases. But sometimes a filter needs logic that does not fit into a single column comparison. Filtering orders by a price range (minimum and maximum) requires parsing a comma-separated value and applying two where clauses. Putting that logic inline would defeat the purpose of the declarative approach.
For these cases, create custom filter classes. If you are already using Custom Query Builders, Spatie Query Builder works seamlessly with scopes defined there:
namespace App\Filters;
use Spatie\QueryBuilder\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;
class OrderTotalRangeFilter implements Filter
{
public function __invoke(Builder $query, mixed $value, string $property): void
{
$range = explode(',', $value);
$query->when(
isset($range[0]) && $range[0] !== '',
fn (Builder $q) => $q->where('total', '>=', $range[0]),
)->when(
isset($range[1]) && $range[1] !== '',
fn (Builder $q) => $q->where('total', '<=', $range[1]),
);
}
}
// Usage
AllowedFilter::custom('total_range', new OrderTotalRangeFilter()),
// Request: GET /api/orders?filter[total_range]=50,200
Consistent Error Responses
When error responses have no consistent structure, every consumer has to handle errors differently depending on whether it is a validation failure, an authentication error, or a server exception. A mobile developer might receive {"message": "Unauthenticated."} for one error and {"error": "Validation failed", "fields": {...}} for another. This forces brittle, special-case error handling on every consumer.
A consistent error format means every error follows the same shape — a message field, an optional errors object with field-specific details, and a proper HTTP status code. Laravel handles this well out of the box for JSON requests, but you need to ensure JSON responses are returned for API routes.
In bootstrap/app.php, configure the exception handler to render JSON for API requests:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(function (Request $request): bool {
return $request->is('api/*') || $request->expectsJson();
});
})
For custom exceptions, implement render():
class InsufficientStockException extends Exception
{
public function __construct(
private readonly Product $product,
private readonly int $requested,
) {
parent::__construct("Insufficient stock for {$product->name}");
}
public function render(Request $request): JsonResponse
{
return response()->json([
'message' => $this->getMessage(),
'errors' => [
'quantity' => ["Only {$this->product->stock} available, {$this->requested} requested."],
],
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
API Versioning
APIs change. You add fields, rename endpoints, or restructure responses. Without versioning, every change is a breaking change — the mobile app release from six months ago stops working the moment you rename a field. Consumers lose trust when their integrations break without warning.
Versioning gives you a path to evolve without breaking existing consumers. The simplest approach in Laravel is route prefixing with version-specific controllers and resources:
// routes/api.php
Route::prefix('v1')->group(function (): void {
Route::apiResource('orders', Api\V1\OrderController::class);
});
Route::prefix('v2')->group(function (): void {
Route::apiResource('orders', Api\V2\OrderController::class);
});
Each version has its own controllers and resources. When v2 needs a different response shape, you create V2\OrderResource without touching v1. Consumers migrate on their own schedule, and you deprecate old versions when adoption drops.
The API Checklist
- Never return raw models — use API Resources for every response
- Use
whenLoaded()— prevent accidental N+1 queries in resources - Use Spatie Query Builder — give consumers flexibility within your rules
- Paginate everything — never return unbounded collections
- Consistent error format — same structure for validation, auth, and server errors
- Version your API — plan for evolution from day one
- Use proper HTTP status codes — 201 for created, 204 for no content, 422 for validation errors
Summary
- An API is a contract. Every response — success or error — should follow a predictable, consistent shape that consumers can rely on without reading your source code.
- Never return Eloquent models directly from API endpoints. Use API Resources to define exactly which fields to expose, how to format them, and which relationships to include. This decouples your API contract from your database schema.
- Use
whenLoaded()in resources to include relationships only when they were eager-loaded, preventing N+1 queries. - Always paginate list endpoints. Returning unbounded collections is a performance and security risk.
spatie/laravel-query-builderprovides declarative filtering, sorting, and relationship inclusion. Consumers get flexibility, but you control exactly which parameters are allowed — unauthorized parameters are silently ignored.- Create custom filter classes for complex filtering logic that does not fit into a single column comparison.
- Standardize error responses in
bootstrap/app.phpso every error — validation, authentication, server — follows the same structure with amessagefield, an optionalerrorsobject, and a proper HTTP status code. - Version your API from day one using route prefixing and version-specific controllers and resources. This lets you evolve the API without breaking existing consumers.
References
- Eloquent: API Resources — Laravel Documentation
- Controllers: API Resource Controllers — Laravel Documentation
- Error Handling — Laravel Documentation
- HTTP Responses: JSON Responses — Laravel Documentation
- spatie/laravel-query-builder — Spatie Documentation
- Filtering — Spatie Query Builder Documentation
- Sorting — Spatie Query Builder Documentation
- Including Relationships — Spatie Query Builder Documentation
- Build an API with Laravel Resources — Laravel News
- Effective API Resource Responses in Laravel — Martin Joo
- API Versioning in Laravel — Laravel News
- Best Practices for Designing a Pragmatic RESTful API — Vinay Sahni
Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take Quiz