The cleanest Laravel APIs I've worked on were never written in API-first style by accident. Someone made a deliberate decision early — usually at the second or third endpoint — that the API contract was the product, and the controllers were just one implementation of it. After that, OpenAPI, versioning, and consistent error shapes stopped being "nice to have" and started being the thing every PR was reviewed against.

API-first does not mean writing OpenAPI YAML by hand before any code. It means treating the contract — the set of routes, payloads, status codes, and error shapes a client can rely on — as a thing that has its own lifecycle, separate from the controllers and models that happen to fulfill it today.

Laravel has been quietly tilting that direction. Since Laravel 11, the API routes file is opt-in via php artisan install:api, which also wires up Sanctum. The scaffolding nudges you toward starting from the API surface, not bolting it on later.

The Routes File Is Your Contract Sketch

In Laravel 11+, a fresh app does not include routes/api.php. You opt in:

Bash
php artisan install:api

That command installs Sanctum, publishes its config, runs the personal_access_tokens migration, and creates the api.php route file under the api/ URI prefix with the EnsureFrontendRequestsAreStateful middleware properly registered in bootstrap/app.php. From that point, your routes file is your first draft of the contract:

PHP
// routes/api.php
use App\Http\Controllers\Api\V1\OrderController;
use Illuminate\Support\Facades\Route;

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

The prefix('v1') and apiResource are the two decisions that matter here. apiResource registers the five RESTful endpoints — index, store, show, update, destroy — and skips the form-rendering routes a web resource includes. v1 in the URL is the most boring versioning strategy, and for that exact reason it is usually the right one.

Picking A Versioning Strategy You Can Live With

The three real options are URL prefix (/api/v1/orders), Accept header (Accept: application/vnd.myapp.v1+json), and subdomain (v1.api.example.com). They are not equally pleasant to maintain.

  • URL prefix is what every junior developer can understand at a glance, what curl examples in your docs read cleanly, and what your reverse proxy can route on without unpacking headers. It is also the easiest to deprecate — delete the controller folder, the route group, and the OpenAPI spec for that version.
  • Header versioning keeps URLs clean and is the academically correct answer if you've been reading Roy Fielding. In practice it makes debugging harder (people forget the header), it complicates browser-based testing, and it's a poor fit for OpenAPI tooling that assumes the URL identifies a specific version.
  • Subdomain versioning matters when versions need radically different infrastructure (different rate limits, different auth, different region routing). Most apps don't.

Pick URL prefix unless you have a specific reason not to. The version belongs in the URL the same way the resource name does — as part of the address, where it is visible.

The Resource Is Half The Contract

A REST endpoint is two things from a client's point of view: the URL it calls and the JSON it gets back. Routes handle the first; JsonResource handles the second. Together they are the contract.

PHP
// app/Http/Resources/V1/OrderResource.php
final class OrderResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'         => $this->id,
            'status'     => $this->status,
            'total_cents'=> $this->total_cents,
            'currency'   => $this->currency,
            'customer'   => CustomerResource::make($this->whenLoaded('customer')),
            'lines'      => OrderLineResource::collection($this->whenLoaded('lines')),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

Two rules I treat as non-negotiable here. The Resource lives in a versioned namespace (V1\OrderResource, V2\OrderResource) so a future change is a new file, not an if. And the controller never returns a model directly — every JSON response goes through a Resource so the contract has exactly one place to live.

Diagram of an API-first Laravel pipeline: an OpenAPI spec (Scramble) on top, feeding into a routes/api.php file with v1 prefix and Sanctum middleware, which dispatches to versioned controllers under Http/Controllers/Api/V1, calling Action classes for business logic, and returning V1/JsonResource transformers. A separate error path shows ProblemJsonRenderer producing RFC 9457 problem+json envelopes for 4xx and 5xx responses.
The contract layer sits above the controllers and outlives them.

OpenAPI From Code, Not The Other Way Around

Hand-writing OpenAPI YAML is a job. Generating it from your existing controllers and Form Requests is not. The Laravel ecosystem has three options worth knowing:

  • dedoc/scramble — generates an OpenAPI 3.1 spec by introspecting your controllers, Form Requests, and Resources at runtime. Zero annotations needed for the common case. Drop the package in, hit /docs/api, and a fully interactive UI is there.
  • vyuldashev/laravel-openapi — attribute-driven, more explicit, more control. Worth it on large APIs where you want the spec to be the source of truth and the code reviewed against it.
  • darkaonline/l5-swagger — wraps the older swagger-php library. Mature, widely used, but the docblock-based annotations feel dated next to Scramble's inference.

For most teams I'd start with Scramble and only graduate to attribute-driven generation when the spec needs to diverge from the code (versioning quirks, deprecated fields you still need to document, request/response examples that the inferred spec can't guess).

The point is the same regardless of tool: your spec is generated from the same code that serves the request. Drift becomes impossible by construction.

A Consistent Error Shape — RFC 9457

Half the pain of consuming someone else's API is parsing seven different error shapes. The fix is to pick one envelope and stick to it. The current standard is RFC 9457 (problem+json), the modern replacement for RFC 7807. The minimum useful payload looks like this:

JSON
{
  "type":     "https://api.example.com/problems/order-conflict",
  "title":    "Order cannot be modified",
  "status":   409,
  "detail":   "Order #1234 was archived on 2025-08-01 and is now read-only.",
  "instance": "/api/v1/orders/1234"
}

You wire it up by extending the exception renderer in bootstrap/app.php:

PHP
// bootstrap/app.php (Laravel 11+)
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (DomainException $e, Request $request) {
        if (! $request->expectsJson()) {
            return null;
        }

        return response()->json([
            'type'     => 'https://api.example.com/problems/' . class_basename($e),
            'title'    => Str::headline(class_basename($e)),
            'status'   => 422,
            'detail'   => $e->getMessage(),
            'instance' => $request->path(),
        ], 422, ['Content-Type' => 'application/problem+json']);
    });
});

For validation errors, Laravel's default response is already pretty good — a 422 with an errors map keyed by field. I leave that alone and use problem+json for the other 4xx and 5xx cases. Two shapes is enough; clients can tell them apart by status code.

Authentication: Sanctum For First-Party, Personal Access Tokens For Third-Party

Sanctum, installed by install:api, gives you two flows from one package: cookie-based session auth for first-party SPAs that share a domain with the API, and bearer-token auth (Authorization: Bearer ...) for everything else. That covers the cases most teams hit.

Where Sanctum stops being enough is OAuth2 — multi-tenant integrations where third-party apps need to ask your users for permission, refresh tokens periodically, and have scoped access. That is Passport's territory. For a typical API-first product (mobile clients, partner integrations with API keys), Sanctum is the right choice and Passport's complexity isn't free.

A small thing worth doing on day one: scope your tokens. $user->createToken('mobile-app', ['orders:read', 'orders:write']) returns a token that can only do what the abilities allow. Then $request->user()->tokenCan('orders:write') becomes a one-liner middleware in front of write endpoints.

A horizontal request trace through an API-first Laravel app. Left to right: a Client (SPA, mobile, or partner) sends a Bearer or cookie credential into routes/api.php with the v1 prefix and Sanctum + throttle middleware, lands at a versioned V1 controller, which calls an Action for the business work and returns a V1\OrderResource. A separate downward branch shows the error path — a thrown exception captured by ProblemJsonRenderer that emits a problem+json envelope (type, title, status, detail, instance) for 4xx and 5xx, while validation errors keep the default 422-with-fields shape.
One request, every layer it crosses, and the two error shapes it can produce.

The Mistakes That Show Up In Code Review

Three patterns I push back on:

  1. Returning paginated lists without meta. When a controller calls User::paginate() and the response is wrapped in UserResource::collection, Laravel adds the links and meta keys for free. When someone "simplifies" by calling ->get()->take(20), the client loses pagination metadata and pages two through ten silently disappear.
  2. Form Requests with no messages() method. The default validation message is fine for English, less fine for the localized client expecting errors.email to be a translation key. Either set messages explicitly or commit to the default — pick one.
  3. One controller for web and api. They share routes, share the model, and now they share validation rules that one team needs and the other doesn't. Split early. The Api/V1/ namespace exists for a reason.

Testing The Contract, Not The Implementation

The tests that matter for an API-first app exercise the public surface — a request goes in, a status code and JSON shape come out. They don't reach into the database to check column values, and they don't import internal Action classes.

PHP
it('returns a 201 with the created order shape', function () {
    Sanctum::actingAs(User::factory()->create(), ['orders:write']);

    $payload = [
        'customer_id' => Customer::factory()->create()->id,
        'lines'       => [
            ['sku' => 'WIDGET-1', 'quantity' => 2],
        ],
    ];

    $this->postJson('/api/v1/orders', $payload)
        ->assertCreated()
        ->assertJsonStructure([
            'data' => ['id', 'status', 'total_cents', 'currency', 'created_at'],
        ])
        ->assertJsonPath('data.status', 'pending');
});

it('returns problem+json for a domain conflict', function () {
    Sanctum::actingAs(User::factory()->create(), ['orders:write']);
    $order = Order::factory()->archived()->create();

    $this->patchJson("/api/v1/orders/{$order->id}", ['status' => 'shipped'])
        ->assertStatus(409)
        ->assertHeader('Content-Type', 'application/problem+json')
        ->assertJsonPath('type', 'https://api.example.com/problems/OrderConflictException');
});

Both tests would survive a complete refactor of the underlying controllers and Actions. That is the whole point — the contract is the thing the test pins down.

A One-Sentence Mental Model

API-first Laravel is a small set of habits — install:api for the routing scaffold, URL versioning, every JSON response through a versioned JsonResource, problem+json for errors, OpenAPI generated from the code, scoped Sanctum tokens — that turn the API surface into a contract you can keep stable while the controllers underneath evolve.