+1 (415) 599-8902

Shipping a Production Laravel API: Sanctum Tokens, Versioning, Rate Limits and Contract Tests

Most Laravel APIs start life as a handful of routes bolted onto an existing web app, and most of them fail the same way: no token scoping, no versioning story, a rate limiter that is either absent or global, and error responses whose shape depends on which line of code threw. None of that hurts until a paying integrator is on the other end.

This tutorial builds the API layer the way we set it up on client projects: a Laravel 13 application, token authentication with Sanctum, resource-shaped responses, versioning that does not fork your codebase, rate limits tied to the customer's plan, an error envelope you can document, idempotent writes, and tests that pin the contract.

Turn on API routing

A fresh Laravel skeleton has no routes/api.php. Add it, along with Sanctum, with one command:

php artisan install:api

That publishes routes/api.php, registers it in bootstrap/app.php with the /api prefix and the api middleware group, installs laravel/sanctum, and adds the personal access tokens migration. Run php artisan migrate and you have a working token store.

If you also serve a first-party SPA from the same domain, keep the stateful cookie flow (statefulApi() middleware) for that client and use tokens only for third parties. Mixing the two is fine; deciding per client which one applies is what matters.

Tokens with abilities, not god tokens

The default createToken('name') produces a token that can do everything the user can. For an API that other companies integrate with, issue narrow abilities instead:

$token = $user->createToken(
    name: $request->string('name'),
    abilities: ['orders:read', 'orders:write'],
    expiresAt: now()->addDays(90),
);

return ['token' => $token->plainTextToken];

Enforce them on the route:

use Illuminate\Support\Facades\Route;

Route::middleware(['auth:sanctum'])->group(function () {
    Route::get('/orders', [OrderController::class, 'index'])
        ->middleware('abilities:orders:read');

    Route::post('/orders', [OrderController::class, 'store'])
        ->middleware('abilities:orders:write');
});

Three habits that pay for themselves:

  • Always set an expiry. A token with no expires_at is a permanent credential sitting in someone else's CI configuration. Ninety days plus a rotation endpoint is a reasonable default; set sanctum.expiration as a backstop.
  • Show the plaintext token exactly once. You only ever have it at creation time; store the hash and let customers rotate rather than recover.
  • Prune. Schedule php artisan sanctum:prune-expired --hours=24 so expired tokens do not accumulate forever.

Abilities are coarse authorization. Keep per-record authorization in policies as usual — an orders:read token still must not read another tenant's orders, and only a policy check enforces that.

Shape responses with API Resources

Returning models directly leaks columns. The day someone adds internal_notes to the table, it is in your public payload. Resources make the contract explicit:

<?php

namespace App\Http\Resources\V1;

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

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->public_id,
            'status' => $this->status,
            'total' => [
                'amount' => $this->total_cents,
                'currency' => $this->currency,
            ],
            'placed_at' => $this->created_at->toIso8601String(),
            'customer' => CustomerResource::make($this->whenLoaded('customer')),
        ];
    }
}

Notes from production:

  • Expose a public identifier (ULID or a prefixed string), not the auto-increment primary key. It stops integrators from enumerating and frees you to change storage later.
  • Send money as integer minor units plus a currency code. Floats in JSON are a support ticket waiting to happen.
  • Send timestamps as ISO 8601 in UTC, always.
  • Use whenLoaded() for relations so a resource can never trigger an N+1 on its own, and pair it with Model::preventLazyLoading() in non-production environments.
  • Paginate collections (OrderResource::collection($orders->paginate(50))) — the meta and links blocks come free and give clients something stable to follow.

Versioning that does not fork your app

URL versioning is the least surprising option for public APIs. The trick is to version the edge, not the domain:

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

Controllers and resources live in App\Http\Controllers\Api\V1 and App\Http\Resources\V1. When v2 arrives, copy only the classes whose payload actually changed and let the rest re-use v1 by extension. Services, models, and actions are never versioned — if version-specific logic is leaking into your domain layer, the change probably belonged in a new field rather than a new version.

Announce deprecations in headers rather than by email alone:

return $response->withHeaders([
    'Deprecation' => 'true',
    'Sunset' => 'Sat, 01 Aug 2026 00:00:00 GMT',
    'Link' => '<https://api.example.com/docs/v2>; rel="deprecation"',
]);

Rate limits per customer, not per app

The default api limiter is 60 requests per minute keyed by user or IP. Real APIs need tiers, and separate budgets for cheap reads and expensive writes:

// bootstrap/app.php or a service provider boot()
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    $user = $request->user();

    if (! $user) {
        return Limit::perMinute(30)->by($request->ip());
    }

    return match ($user->plan) {
        'enterprise' => Limit::perMinute(1200)->by($user->id),
        'growth' => Limit::perMinute(300)->by($user->id),
        default => Limit::perMinute(60)->by($user->id),
    };
});

RateLimiter::for('exports', fn (Request $request) => [
    Limit::perMinute(5)->by($request->user()->id),
    Limit::perDay(200)->by($request->user()->id),
]);

Apply the named limiter with ->middleware('throttle:exports'). Laravel returns 429 with Retry-After and X-RateLimit-* headers automatically; document them so clients back off instead of hammering.

Use a shared cache store (Redis) for the limiter, otherwise every web node enforces its own private budget and your real limit is the number you configured multiplied by your server count.

One error envelope

Decide the shape once and force everything through it. In bootstrap/app.php:

->withExceptions(function (Illuminate\Foundation\Configuration\Exceptions $exceptions) {
    $exceptions->shouldRenderJsonWhen(fn ($request) => $request->is('api/*'));

    $exceptions->render(function (Throwable $e, $request) {
        if (! $request->is('api/*')) {
            return null;
        }

        $status = match (true) {
            $e instanceof Illuminate\Validation\ValidationException => 422,
            $e instanceof Illuminate\Auth\AuthenticationException => 401,
            $e instanceof Illuminate\Auth\Access\AuthorizationException => 403,
            $e instanceof Illuminate\Database\Eloquent\ModelNotFoundException => 404,
            $e instanceof Symfony\Component\HttpKernel\Exception\HttpExceptionInterface => $e->getStatusCode(),
            default => 500,
        };

        return response()->json([
            'error' => [
                'type' => class_basename($e),
                'message' => $status === 500 ? 'Server error.' : $e->getMessage(),
                'errors' => $e instanceof Illuminate\Validation\ValidationException ? $e->errors() : null,
                'request_id' => $request->header('X-Request-Id', (string) Str::uuid()),
            ],
        ], $status);
    });
})

Echo the request_id in a response header too, and log it. "Give me the request id" turns a two-day support thread into a two-minute log query.

Make writes idempotent

Mobile clients retry. Webhook senders retry. Queues retry. If POST /v1/orders is not idempotent you will eventually double-charge someone. Accept an Idempotency-Key header and replay the stored response:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class Idempotent
{
    public function handle(Request $request, Closure $next)
    {
        $key = $request->header('Idempotency-Key');

        if (! $key || ! $request->isMethod('POST')) {
            return $next($request);
        }

        $cacheKey = 'idem:'.$request->user()->id.':'.sha1($key.'|'.$request->path());

        if ($cached = Cache::get($cacheKey)) {
            return response()->json($cached['body'], $cached['status'])
                ->header('Idempotent-Replay', 'true');
        }

        $response = $next($request);

        if ($response->isSuccessful()) {
            Cache::put($cacheKey, [
                'body' => json_decode($response->getContent(), true),
                'status' => $response->getStatusCode(),
            ], now()->addHours(24));
        }

        return $response;
    }
}

For money-moving endpoints, back this with a database row and a unique constraint rather than a cache entry — a cache eviction should never be able to create a second charge.

Pin the contract with tests

Controllers get refactored; the payload must not move. Assert the exact JSON structure, not just a status code:

<?php

use App\Models\Order;
use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('returns orders in the documented shape', function () {
    $user = User::factory()->has(Order::factory()->count(3))->create();

    Sanctum::actingAs($user, ['orders:read']);

    $this->getJson('/api/v1/orders')
        ->assertOk()
        ->assertJsonStructure([
            'data' => [['id', 'status', 'total' => ['amount', 'currency'], 'placed_at']],
            'meta' => ['current_page', 'total'],
        ]);
});

it('rejects a token without the write ability', function () {
    Sanctum::actingAs(User::factory()->create(), ['orders:read']);

    $this->postJson('/api/v1/orders', [])->assertForbidden();
});

it('never returns another tenant\u2019s orders', function () {
    $mine = User::factory()->has(Order::factory())->create();
    $theirs = Order::factory()->create();

    Sanctum::actingAs($mine, ['orders:read']);

    $this->getJson("/api/v1/orders/{$theirs->public_id}")->assertNotFound();
});

That third test is the one that matters most. Return 404 rather than 403 for records outside the caller's scope so the API does not confirm that an id exists.

Generate documentation from the same source of truth — Scramble reads your routes, form requests and resources to produce an OpenAPI document, which keeps the docs honest as the code changes.

A short pre-launch checklist

  • Tokens are scoped, expiring, and prunable; nothing uses a full-access token in production.
  • Every list endpoint is paginated and every relation is eager-loaded deliberately.
  • Rate limits are per-customer, backed by Redis, and documented with their headers.
  • All errors come out of one renderer, with a request id.
  • Writes that create or move money are idempotent and covered by a unique constraint.
  • Structure tests exist for every public payload, plus a cross-tenant access test per resource.
  • HTTPS enforced, CORS restricted to known origins, and payload size and upload limits set at the edge.

None of this is exotic. It is the difference between an API that survives its first serious integrator and one that generates support tickets from day one.

If you are designing or hardening a Laravel API, we do this work for clients every week — see our API development in Laravel service, or get in touch with your scope and timeline.