Building Robust REST APIs with Laravel

4 min readUpdated on

A dependable API is a contract at the edge of your application. Clients need stable names, predictable pagination, useful errors and safe retries. Laravel provides the building blocks, but production quality comes from deliberate compatibility, authorization and failure policies.

Start with a compatibility policy

Version a public API when independently deployed clients need an explicit promise. /api/v1/products is easy to document and sunset; it is not a ritual for a private Inertia backend released with the server. After publishing v1, do not quietly change a field's meaning: introduce v2 beside it and state the migration window.

php
<?php

declare(strict_types=1);

use App\Http\Controllers\Api\V1\ProductController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->middleware('auth:sanctum')->group(function (): void {
    Route::apiResource('products', ProductController::class);
});

apiResource gives a familiar surface. Let route model binding resolve models and policies decide access; do not duplicate ownership checks throughout controllers.

Separate input, use case and representation

A Form Request validates HTTP input, an action performs the use case, and a Resource owns the public representation. This prevents an incidental model column becoming part of the API forever.

php
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class ProductResource extends JsonResource
{
    /** @return array<string, int|string> */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->resource->getKey(),
            'name' => $this->resource->name,
            'price_cents' => $this->resource->price_cents,
        ];
    }
}

Return ProductResource::collection($products), not an Eloquent collection. The Resource is the one place to add links, renamed fields and conditional relations without leaking internals.

Give tokens narrow abilities

Sanctum tokens are credentials, not roles. Issue only the abilities an integration needs, then enforce them in middleware. A reporting token should not delete products.

php
<?php

declare(strict_types=1);

$token = $user->createToken('warehouse-sync', ['products:read']);

Route::get('/v1/products', ProductController::class)
    ->middleware(['auth:sanctum', 'abilities:products:read']);

Use policies for user-level authorization and abilities for this particular token. Rotate and revoke tokens, record their purpose, and never put a long-lived token in browser JavaScript.

Pick pagination for the query

Offset pagination (paginate()) supports totals and numbered pages, but inserts can move items between pages and large offsets become expensive. Cursor pagination (cursorPaginate()) seeks after a stable indexed ordering, so it suits activity feeds and large append-heavy tables.

php
<?php

declare(strict_types=1);

$products = Product::query()
    ->orderBy('id')
    ->cursorPaginate(perPage: 50);

return ProductResource::collection($products);

Cursor order must be deterministic. Order by an indexed, unique tie-breaker such as created_at, id; do not cursor-paginate an unstable calculated value. Use offset pagination when a human genuinely needs page 42.

Make failures machine-readable

RFC 9457 Problem Details gives validation, authorization and application failures one recognizable shape. Return application/problem+json, a stable type, short title, HTTP status, an instance or correlation ID, and field errors only for validation. Centralise mapping in Laravel's exception configuration; controllers should return successful resources, not build inconsistent error arrays.

json
{
  "type": "https://api.example.com/problems/validation",
  "title": "The request is invalid.",
  "status": 422,
  "detail": "One or more fields need attention.",
  "errors": {"name": ["The name field is required."]}
}

Never send exception messages, SQL or traces to a client.

Rate-limit the protected capability

Named limiters belong in a provider, where the key and policy are reviewable:

php
<?php

declare(strict_types=1);

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('partner-api', function (Request $request): Limit {
    return Limit::perMinute(120)->by((string) $request->user()?->getAuthIdentifier());
});

Attach throttle:partner-api to the route group. For anonymous traffic, key on a trusted identity; an untrusted forwarded-IP header is not one. Return 429 with retry information and alert on sustained limiting rather than silently breaking a partner.

Pitfalls and when not to use this

Do not add versioned REST only because it sounds modern: an internal form does not need a public compatibility promise. Do not use cursors for arbitrary sorts without an index and tie-breaker. Do not treat Sanctum abilities as a complete permissions model. Finally, an HTTP retry is not exactly-once processing: payments and webhooks need an idempotency key plus a unique database constraint. Feature-test each public claim—success shape, expected failure, authorization, pagination and retry—before publishing the contract.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.