Laravel makes it almost too easy to return JSON. A route closure with return $users; technically works β€” and that convenience is exactly how APIs end up leaking database columns, breaking mobile clients on every refactor, and falling over under a burst of traffic. Building an API you can support for years requires four deliberate decisions: how you shape responses, how you authenticate, how you version, and how you throttle. Let's walk through each.

Start with install:api

Modern Laravel applications no longer ship with API routes by default. The first step on a fresh project is a single command:

Start with install:api β€” Building Production-Ready APIs in Laravel: Resources, Sanctum, and Versioning
Start with install:api
php artisan install:api

This creates routes/api.php, registers it in bootstrap/app.php with the /api prefix, and installs Laravel Sanctum along with its migration for the personal access tokens table. From there, every route you define in that file is automatically stateless and wrapped in the api middleware group.

Shape every response with API resources

The single most important habit for API longevity is never returning Eloquent models directly. An API resource is a transformation layer that gives you an explicit contract between your database schema and your public payload:

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'status' => $this->status,
            'total' => $this->total_cents / 100,
            'placed_at' => $this->created_at->toIso8601String(),
            'customer' => new CustomerResource($this->whenLoaded('customer')),
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
        ];
    }
}

Two details in that example carry most of the value. First, the payload is curated β€” internal columns like internal_notes or stripe_customer_id simply never appear, so adding a column to the table can never accidentally expose it. Second, whenLoaded() only includes relationships that were explicitly eager loaded, which means your resource layer can never trigger an N+1 query on its own. Pair it with OrderResource::collection($orders) for lists, and pagination metadata (links, per-page counts) is added automatically when you pass a paginator.

Authenticate with Sanctum tokens

For first-party SPAs, mobile apps, and simple third-party integrations, Sanctum hits the sweet spot between "no auth" and a full OAuth2 server like Passport. Issuing a token is one line, and abilities give you coarse-grained scoping:

$token = $user->createToken('mobile', ['orders:read', 'orders:create']);

return response()->json(['token' => $token->plainTextToken]);

Protect routes with the auth:sanctum middleware, and check abilities inside controllers or policies with $request->user()->tokenCan('orders:create'). A few practices worth adopting from day one:

  • Name tokens meaningfully ("mobile", "zapier-integration") so users can review and revoke them individually.
  • Set an expiration via the expiration option in config/sanctum.php, or pass a per-token expiresAt β€” long-lived tokens that never rotate are a liability.
  • Use cookie-based SPA authentication instead of tokens when the consumer is your own first-party SPA on the same domain; you get CSRF protection and HttpOnly cookies for free.
  • Prune expired tokens on a schedule with Schedule::command('sanctum:prune-expired')->daily().

Version from the very first release

Versioning feels like premature ceremony right up until the day a mobile release in app-store review depends on a payload you want to change. Adding a version prefix costs nothing now and saves a migration project later. The pragmatic approach in Laravel is URI versioning with grouped route files:

Version from the very first release β€” Building Production-Ready APIs in Laravel: Resources, Sanctum, and Versioning
Version from the very first release
// bootstrap/app.php
->withRouting(
    api: __DIR__.'/../routes/api.php',
    apiPrefix: 'api/v1',
)

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

Keep versioning at the edges: version your routes and your resource classes (V1\OrderResource, V2\OrderResource), but let controllers, actions, and models stay version-agnostic wherever possible. Most v2s change response shape, not business logic, so the resource layer is usually the only thing that genuinely forks.

Rate limiting that matches reality

Laravel's throttle middleware is backed by named rate limiters, and the default api limiter (60 requests per minute per user or IP) is defined for you. Real APIs usually need more nuance β€” cheaper limits for anonymous traffic, generous limits for authenticated users, and strict limits on expensive endpoints:

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

RateLimiter::for('exports', fn (Request $request) =>
    Limit::perHour(10)->by($request->user()->id)
);

Clients receive standard X-RateLimit-Remaining headers and a 429 with a Retry-After when they exceed the limit, so well-behaved consumers can back off automatically. If you need finer control, recent Laravel releases also support per-second limits, which are useful for smoothing bursts rather than capping volume.

Consistent errors are part of the contract

Laravel automatically renders validation failures as 422 JSON responses with an errors object, which is a good baseline. Extend the same discipline to the rest of your error surface: register custom renderings in bootstrap/app.php with $exceptions->render() so domain exceptions map to stable status codes and machine-readable error codes. A client that can rely on {"message": ..., "code": "insufficient_stock"} for every failure is dramatically easier to build against than one parsing prose.

Consistent errors are part of the contract β€” Building Production-Ready APIs in Laravel: Resources, Sanctum, and Versioning
Consistent errors are part of the contract

Pagination, filtering, and sorting: decide the conventions once

Every collection endpoint eventually needs pagination, filtering, and sorting β€” and nothing erodes an API's usability faster than each endpoint inventing its own query-parameter dialect. Pick conventions on day one and apply them everywhere:

GET /api/v1/orders?status=processing&sort=-created_at&per_page=25&page=2

Laravel's paginate() already returns meta and links blocks that mobile clients can consume directly. For filtering, whitelist the filterable fields explicitly β€” passing request input straight into where() clauses is how APIs leak columns they never meant to expose and acquire accidental full-table scans. The spatie/laravel-query-builder package formalizes exactly this pattern (allowed filters, allowed sorts, allowed includes) and is worth its dependency weight on any API with more than a handful of list endpoints.

One convention teams skip and regret: cap per_page. A client asking for per_page=100000 should get 100 rows and a clear response, not a memory spike and a 30-second query.

Tests and documentation are part of the API

An untested API contract is a rumor. Laravel's HTTP tests make endpoint coverage almost free, and the assertions worth writing are about the contract, not the framework:

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

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

Cover the unhappy paths too: unauthenticated requests return 401 JSON (not a login redirect), validation failures return 422 with field-level errors, and requesting another user's resource returns 404 or 403 β€” whichever your policy chooses, consistently. For documentation, generate it from the code instead of maintaining it by hand: Scribe reads your routes, form requests, and resources and emits OpenAPI + readable HTML docs. A documented API gets integrated in an afternoon; an undocumented one generates a week of Slack questions.

Wrapping up

None of these practices is difficult in isolation β€” Laravel gives you first-class tools for each. The discipline is in adopting all of them before you have consumers: resources so your schema stays private, Sanctum with expiring scoped tokens, a v1 prefix from the first deploy, and rate limits tuned per audience. Do that, and the API you ship this month will still be pleasant to maintain when v3 rolls around.

Related Service

πŸ’» Web Development

Custom websites and web applications built with PHP, Laravel, WordPress, and React β€” fast, secure, scalable, and tailored to your business goals.

Explore Web Development →
Share this article
X Facebook LinkedIn