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:
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:
// 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.
// 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.
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 olderswagger-phplibrary. 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:
{
"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:
// 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.
The Mistakes That Show Up In Code Review
Three patterns I push back on:
- Returning paginated lists without
meta. When a controller callsUser::paginate()and the response is wrapped inUserResource::collection, Laravel adds thelinksandmetakeys for free. When someone "simplifies" by calling->get()->take(20), the client loses pagination metadata and pages two through ten silently disappear. - Form Requests with no
messages()method. The default validation message is fine for English, less fine for the localized client expectingerrors.emailto be a translation key. Either set messages explicitly or commit to the default — pick one. - One controller for
webandapi. They share routes, share the model, and now they share validation rules that one team needs and the other doesn't. Split early. TheApi/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.
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.



