The Dev Log › Programming
Designing Clean REST APIs in Laravel: Resources, Versioning and Errors
By Jezer Niel Blanca, Full Stack Developer ·
·
6 min read
How I design predictable REST APIs in Laravel 12: resource routes, API resources, URL versioning, safe pagination and filtering, precise status codes and JSON errors.
A good API is boring in the best possible way. Endpoints are predictable, responses look the same everywhere, errors are easy to handle, and nothing breaks when the backend evolves. Much of my day-to-day work involves API integrations, and I've learned to appreciate APIs that are consistent far more than APIs that are clever. In this post I'll share how I design REST APIs in Laravel 12: resource-oriented routes, API resources for consistent output, versioning, pagination, filtering, and error responses that client developers will actually thank you for.
Start With Resources and Predictable Routes
REST works best when URLs describe things and HTTP methods describe actions. Laravel's resource routes give you that structure for free. If your project doesn't have API routing set up yet, php artisan install:api creates routes/api.php and installs Sanctum for token authentication.
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\ProjectTaskController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('api.v1.')->middleware('auth:sanctum')->group(function () {
Route::apiResource('projects', ProjectController::class);
Route::apiResource('projects.tasks', ProjectTaskController::class)->shallow();
});
apiResource registers the five standard routes without the HTML-only create and edit pages. The nested resource with shallow() gives you /projects/{project}/tasks for listing and creating, and /tasks/{task} for everything else, which keeps URLs short.
Naming Conventions I Stick To
- Plural nouns for collections:
/projects, not /project or /getProjects.
- Kebab-case for multi-word paths:
/time-entries.
- Nesting only one level deep. Deeper nesting makes URLs brittle.
- Actions as sub-resources when something doesn't fit CRUD, like
POST /projects/{project}/archive.
Use API Resources for Consistent Output
Returning Eloquent models directly from controllers is tempting, but it couples your API to your database columns. Rename a column and every client breaks. API resources give you a stable layer in between:
php artisan make:resource ProjectResource --no-interaction
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'status' => $this->status,
'budget' => $this->budget,
'owner' => UserResource::make($this->whenLoaded('owner')),
'tasks_count' => $this->whenCounted('tasks'),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}
whenLoaded and whenCounted only include related data if you eager loaded it, so you never trigger surprise N+1 queries from inside a resource. I also always format dates as ISO 8601 so every client parses them the same way.
A Thin Controller
With a form request handling validation and a resource handling output, the controller stays tiny:
public function store(StoreProjectRequest $request): JsonResponse
{
$project = $request->user()->projects()->create($request->validated());
return ProjectResource::make($project)
->response()
->setStatusCode(201);
}
Return 201 Created for new records, 200 for reads and updates, and 204 No Content for deletes with response()->noContent().
Version From Day One
Once someone depends on your API, changing it is expensive. Versioning gives you room to evolve. I use a URL prefix like /api/v1 because it's obvious, easy to test in a browser, and easy to route.
Keep each version's controllers and resources in their own namespace, such as App\Http\Controllers\Api\V1. When you need a breaking change, create a V2 namespace for only the endpoints that change, and keep V1 running until clients have migrated.
A breaking change is anything that could make an existing client fail: removing a field, renaming it, changing its type or changing what an endpoint requires. Adding new optional fields is not breaking.
Pagination, Filtering and Sorting
Never return an unbounded list. Paginated resource collections include data, links and meta automatically:
public function index(Request $request): AnonymousResourceCollection
{
$sortable = ['name', 'created_at', 'budget'];
$sort = in_array($request->query('sort'), $sortable, true) ? $request->query('sort') : 'created_at';
$direction = $request->query('direction') === 'asc' ? 'asc' : 'desc';
$projects = $request->user()->projects()
->with('owner')
->withCount('tasks')
->when($request->query('status'), fn ($query, $status) => $query->where('status', $status))
->when($request->query('search'), fn ($query, $search) => $query->whereLike('name', "%{$search}%"))
->orderBy($sort, $direction)
->paginate(min((int) $request->query('per_page', 15), 100))
->withQueryString();
return ProjectResource::collection($projects);
}
A few details worth copying:
- Allowlist sort columns. Column names can't be parameter-bound, so never pass user input straight into
orderBy.
- Cap
per_page. Otherwise someone will request a million rows.
- Keep query strings with
withQueryString() so pagination links preserve filters.
- Eager load what the resource needs, so the whole page runs in a handful of queries.
Cursor Pagination for Large or Live Data
For feeds, logs or very large tables, cursorPaginate() is often better than offset pagination. It stays fast deep into the results and doesn't skip or repeat items when new rows are inserted while someone is paging.
Errors That Clients Can Handle
Clients need errors that are consistent and machine-readable. Laravel already returns validation errors as JSON with a 422 status and an errors object keyed by field, which is a great format to keep.
Make sure every API route returns JSON errors, even when a client forgets the Accept header. In bootstrap/app.php:
use Illuminate\Http\Request;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson(),
);
})
Status Codes Worth Using Precisely
- 400 for malformed requests.
- 401 when the user isn't authenticated, and 403 when they are but aren't allowed.
- 404 when a resource doesn't exist, including records the user isn't allowed to know about.
- 409 for conflicts, like trying to archive something already archived.
- 422 for validation errors.
- 429 when rate limits are exceeded.
Never leak stack traces or SQL errors in production responses. With APP_DEBUG=false, Laravel returns a generic message for server errors, and your logs keep the details.
Protect and Document
Two final pieces make an API production-ready. First, rate limiting: define a limiter with RateLimiter::for('api', ...) and apply throttle:api to your routes so a single client can't overwhelm you. Second, documentation: even a short page listing endpoints, parameters, example responses and error codes saves client developers hours. Feature tests that hit each endpoint double as living documentation of how it behaves.
Wrapping up
Clean REST APIs in Laravel come from a handful of consistent choices: resource routes with plural nouns, API resources between your models and your clients, URL versioning from the start, capped and filterable pagination, precise status codes and JSON errors everywhere. None of it is complicated, and all of it compounds. If you need an API or integration built properly for your product, I'd love to build it with you and my team.
Tags: Laravel, REST API, API Design, PHP