Hasina Razafintsalama

Hasina RAZAFINTSALAMA

← Back to Blog
Backend

Designing a Robust, Scalable REST API with Laravel

Laravel builds a REST API fast, but fast is not production-ready. Here are the structural decisions that decide whether it scales: structure, resources, validation, versioning, auth, rate limiting, pagination, errors, docs and tests.

2026-05-28·12 min

Laravel is one of the best frameworks for building a REST API quickly, but quick is not production-ready by default. A handful of structural decisions made at the start decide whether the API scales gracefully or turns into a maintenance burden. This is the checklist to run through before you ship the first version.

Project structure

Namespace controllers under Api/V1 and keep them thin. The controller accepts a validated request, calls one action or service, and returns a resource. Validation lives in Form Requests, output shaping in API Resources, business logic in Action or Service classes. A controller method longer than a few lines usually means logic that belongs elsewhere.

php
// app/Http/Controllers/Api/V1/OrderController.php
public function store(StoreOrderRequest $request, CreateOrder $createOrder)
{
    $order = $createOrder->handle($request->toDto());

    return (new OrderResource($order))
        ->response()
        ->setStatusCode(201);
}

API Resources, not raw models

Returning Eloquent models directly couples clients to your database schema and leaks internal fields. An API Resource is the explicit, stable contract between your data and your consumers. Use whenLoaded so relations appear only when eager-loaded, which also keeps you honest about N+1.

php
class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'total' => $this->total,
            'status' => $this->status,
            'created_at' => $this->created_at->toISOString(),
            'customer' => new CustomerResource($this->whenLoaded('customer')),
        ];
    }
}

Validation with Form Requests

Put every rule in a Form Request, not the controller. It keeps validation in one place, gives you authorize() for policy checks on the same object, and returns a consistent 422 automatically. Cast the validated data into a DTO so the rest of the code works with typed values.

php
class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Order::class);
    }

    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'integer', 'exists:customers,id'],
            'lines' => ['required', 'array', 'min:1'],
            'lines.*.sku' => ['required', 'string'],
            'lines.*.quantity' => ['required', 'integer', 'min:1'],
        ];
    }
}

Versioning from day one

Use URI versioning: a route group per version under /api/v1, each with its own controllers and resources. It costs nothing to add up front and lets you run the old and new shapes side by side. Keep the previous version running until clients have migrated, and announce a removal date well ahead.

php
// routes/api.php
Route::prefix('v1')
    ->name('api.v1.')
    ->group(base_path('routes/api/v1.php'));

Authentication: Sanctum, Passport or JWT

Sanctum for your own SPA or mobile client, which covers most cases with minimal overhead. Passport when you need a full OAuth2 server for third-party clients. JWT only when several independent services must verify the same token without a shared session store, a narrower case with its own trade-offs.

Rate limiting

Define named rate limiters keyed by user or API key, apply a default to the whole API, and set tighter limits on expensive or sensitive routes. Return 429 with a Retry-After header so clients back off correctly.

php
RateLimiter::for('api', fn (Request $request) =>
    $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(20)->by($request->ip())
);

Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::apiResource('orders', OrderController::class);
});

Pagination, filtering and sorting

Paginate every list endpoint with a default page size and a hard maximum, and prefer cursorPaginate for large or fast-changing datasets. For filtering and sorting, whitelist the allowed fields explicitly rather than passing request input to the query builder.

php
$orders = QueryBuilder::for(Order::class)
    ->allowedFilters(['status', 'customer_id'])
    ->allowedSorts(['created_at', 'total'])
    ->allowedIncludes(['customer'])
    ->cursorPaginate(50);

Consistent error responses

Map every exception to one format in the global handler: RFC 9457 problem details, served as application/problem+json. Validation, not-found, authorization and rate-limit errors all come out with the same shape, so clients write one error path.

Documentation and tests

Generate an OpenAPI description from the code (a package like Scramble reads your Form Requests and Resources) so the docs cannot drift from the implementation. Cover each endpoint with a feature test that asserts the status, the JSON structure and the authorization rules.

php
public function test_it_lists_orders_for_the_authenticated_user(): void
{
    $user = User::factory()->has(Order::factory()->count(3))->create();

    $this->actingAs($user)
        ->getJson('/api/v1/orders')
        ->assertOk()
        ->assertJsonCount(3, 'data')
        ->assertJsonStructure(['data' => [['id', 'total', 'status']]]);
}

The building blocks, in one place

ConcernBuilding block
StructureThin controllers under Api/V1, logic in actions
OutputAPI Resources as the contract, whenLoaded for relations
InputForm Requests with rules() and authorize()
VersioningURI prefix, one route group per version
AuthSanctum by default, Passport for OAuth2, JWT for multi-service
AbuseNamed rate limiters, 429 with Retry-After
ListsAlways paginate, whitelist filters and sorts
ErrorsRFC 9457 problem details from the global handler
Docs and testsOpenAPI generated from code, a feature test per endpoint

Every item on this list is cheap to add to the first version and expensive to retrofit once the API has clients. Spend an hour on the checklist before you ship.

FAQ

How should I structure a Laravel REST API?
Namespace controllers under Api/V1, keep them thin, and push work outward: Form Requests for validation, API Resources for output, and Action or Service classes for business logic. The controller accepts a validated request, calls one thing, and returns a resource.
Sanctum, Passport or JWT for a Laravel API?
Sanctum for your own SPA or mobile client, which is most cases. Passport when you need a full OAuth2 server for third-party clients. JWT only when several independent services must verify the same token without a shared session store.
How do I version a Laravel API?
URI versioning is the simplest: a route group per version under /api/v1, /api/v2, each with its own controllers and resources. Keep the previous version running until clients migrate, and announce a removal date well in advance.
Should I use API Resources?
Yes. Returning Eloquent models directly couples clients to your database schema and leaks internal fields. An API Resource is the explicit, stable contract between your data and your consumers, and it is where you control includes, formatting and visibility.
How do I paginate a large list in a Laravel API?
Use paginate or cursorPaginate with a default page size and a hard maximum. Cursor pagination is the right choice for large or fast-changing datasets because it does not skip or duplicate rows when data changes between page requests.

A production-grade Laravel API is not more code than a quick one, it is the same code with the structure decided on purpose. Run this checklist for the first version and the API stays easy to change as the product and the traffic grow.

Need help with this topic? REST API Design

Discover this service