Authentication with Sanctum, versioning, resource classes, consistent error responses, rate limiting, pagination, and API testing with Pest — the full implementation that goes from php artisan new to a production-ready API your frontend team will actually enjoy consuming.
Three weeks. That’s how long a team spent standardizing responses on a client project before they could safely add a single new feature — inherited from a previous developer who mixed response formats across endpoints, skipped versioning entirely, and hardcoded auth logic directly into controllers. Nothing about that codebase was broken in the sense of throwing errors. Every endpoint worked. The problem was that no two endpoints worked the same way — one returned {data: [...]}, another returned a bare array, a third wrapped errors in {error: "..."} while a fourth used {message: "...", errors: {...}}. A frontend team consuming that API couldn’t write one error-handling function and reuse it; they had to special-case almost every single endpoint.
That’s the actual cost of skipping structure at the start of an API — not a broken feature, but a tax paid on every single thing built afterward. This is the complete build: Sanctum for authentication (and why it’s the right choice over Passport for an API you own both ends of), URL-based versioning from the first route, API Resources for response shaping, one consistent error envelope for every failure mode, rate limiting that scales with plan tier instead of punishing real users, pagination that doesn’t break when the underlying dataset does, and a Pest test suite that proves the whole thing actually works — not just that it compiles.
Sanctum, and Why It’s the Right Default Here
Laravel ships two first-party auth packages, and picking the wrong one costs real time. Passport is a full OAuth2 server — authorization codes, client credentials, scopes, RSA keys — built for when third-party applications need to authenticate against your API on a user’s behalf. Sanctum is deliberately simpler: personal access tokens sent as an Authorization: Bearer header, no OAuth ceremony. If the backend and the frontend (or mobile app) are both owned by the same team, Sanctum is the right default — less configuration, fewer moving parts, nothing to manage that isn’t directly needed.
composer require laravel/sanctum
php artisan install:api
// app/Models/User.php
class User extends Authenticatable
{
use HasApiTokens;
}
// routes/api.php
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', fn (Request $request) => $request->user());
});
// app/Http/Controllers/AuthController.php
class AuthController extends Controller
{
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$token = $user->createToken('api-token')->plainTextToken;
return response()->json([
'data' => ['user' => new UserResource($user), 'token' => $token],
], 201);
}
public function login(LoginRequest $request): JsonResponse
{
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken('api-token')->plainTextToken;
return response()->json([
'data' => ['user' => new UserResource($user), 'token' => $token],
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out successfully']);
}
}
Token abilities — the feature most implementations skip entirely, and shouldn’t. A token isn’t just “authenticated or not” — Sanctum lets a single token be scoped to specific abilities, which matters the moment there’s more than one kind of client (a first-party mobile app that needs full access, versus a limited integration token generated for a specific third-party use case).
// Issuing a scoped token — this token can only read, never write
$token = $user->createToken('readonly-integration', ['posts:read'])->plainTextToken;
// Full-access token for the first-party mobile app
$token = $user->createToken('mobile-app', ['*'])->plainTextToken;
// Enforcing the ability in a route
Route::middleware(['auth:sanctum', 'ability:posts:read'])->group(function () {
Route::get('/posts', [PostController::class, 'index']);
});
Starting with abilities even for a v1 that only has one type of client is worth the small upfront cost — retrofitting scoped tokens after several client types already share the same unscoped token type means auditing every existing token in production and deciding what ability set each one should have retroactively, which is a much larger job than declaring abilities from the first createToken() call.
Tokens don’t expire by default — they live until explicitly revoked. For most APIs this is worth changing:
// config/sanctum.php
'expiration' => 60 * 24 * 14, // 14 days, in minutes
Versioning From the First Route, Not the First Breaking Change
The single most expensive mistake in API design is treating versioning as something to add later, once it’s needed. By the time it’s needed, there’s a live client depending on the unversioned shape, and introducing /v1/ retroactively means either breaking that client or running two parallel unversioned-and-versioned route sets indefinitely.
// routes/api.php
Route::prefix('v1')->name('api.v1.')->group(base_path('routes/api_v1.php'));
// routes/api_v1.php
Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
app/Http/Controllers/Api/
V1/
PostController.php
V2/
PostController.php -- created only once V2 is actually needed
Separate controller namespaces per version — not a single controller branching on a version parameter internally — is what keeps this maintainable. A V1 controller can keep behaving exactly as documented forever, while a V2 controller evolves independently, without either one accumulating conditional logic checking “if version 2, do this differently” scattered through shared code.
When a breaking change actually ships in V2, tell V1 clients explicitly instead of leaving them to find out from a changelog:
// A middleware applied to V1 routes once V2 exists
class DeprecationHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
return $response;
}
}
Clients that actually read response headers — which, for any API consumed by another engineering team rather than a single frontend under the same org, is worth assuming some of them do — catch the deprecation early instead of discovering it the day the sunset date arrives and the old version stops responding.
API Resources — One Shape, Every Time
Returning $post directly from a controller means every column on the model — including ones that should never leave the server, like password or an internal stripe_customer_id — is exposed by default unless someone remembers to hide it, model by model, forever. API Resources invert that: the resource class is an explicit, reviewable allowlist of what actually gets serialized.
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'published_at' => $this->published_at?->toIso8601String(),
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
];
}
}
whenLoaded('author') is the detail that keeps this resource N+1-safe — it only includes the relationship in the output if it was actually eager-loaded on the query that produced this model, and returns nothing (not a query-triggering lazy load) if it wasn’t. A resource class that instead wrote new UserResource($this->author) unconditionally would silently reintroduce an N+1 query on every single post in a collection, exactly the bug class covered in eager-loading discipline elsewhere — except here it’s hidden inside a resource transformer, one layer further from the controller than most people think to check.
// Collections — wrapped consistently, with pagination meta included automatically
class PostController extends Controller
{
public function index(): AnonymousResourceCollection
{
return PostResource::collection(
Post::with(['author', 'tags'])->latest()->paginate(20)
);
}
}
{
"data": [
{ "id": 1, "title": "...", "author": { "id": 3, "name": "..." } }
],
"links": { "first": "...", "last": "...", "prev": null, "next": "..." },
"meta": { "current_page": 1, "last_page": 8, "per_page": 20, "total": 152 }
}
Every list endpoint in the API returning this exact shape — data, links, meta — is what lets a frontend team write one pagination component that works against every endpoint in the API, instead of a bespoke one per endpoint because each one happened to shape its response slightly differently.
One Error Envelope, No Exceptions to the Rule
This is the fix for the three-weeks-of-standardization problem from the opening. Every failure mode — validation, authentication, not-found, server error — needs to return through the same shape, or a frontend team ends up writing conditional error-handling logic per endpoint instead of one shared handler.
// app/Exceptions/Handler.php (or bootstrap/app.php's ->withExceptions() in newer Laravel)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ValidationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'The given data was invalid.',
'errors' => $e->errors(),
], 422);
}
});
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
});
$exceptions->render(function (ModelNotFoundException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json(['message' => 'Resource not found.'], 404);
}
});
$exceptions->render(function (Throwable $e, Request $request) {
if ($request->is('api/*') && ! app()->isLocal()) {
return response()->json([
'message' => 'An unexpected error occurred.',
], 500);
}
});
})
The unhandled-exception catch-all matters as much as the specific handlers above it. Without it, an unexpected exception in production — a database connection blip, a third-party API timeout inside a controller — falls through to Laravel’s default exception rendering, which in a non-local environment might return an HTML error page or a differently-shaped JSON error than every other endpoint in the API. A frontend expecting {message: "..."} and receiving an HTML 500 page is a client-side crash on top of the original server error, and it’s exactly the kind of gap that’s invisible until the specific unhandled exception actually happens in production for the first time.
// A shared trait for controllers that need a consistent success envelope too
trait ApiResponses
{
protected function success(mixed $data, int $status = 200): JsonResponse
{
return response()->json(['data' => $data], $status);
}
protected function message(string $message, int $status = 200): JsonResponse
{
return response()->json(['message' => $message], $status);
}
}
Consistency here isn’t a style preference — it’s what makes an API’s error handling something a frontend team writes once, tests once, and never has to special-case per endpoint.
Rate Limiting That Doesn’t Punish Real Users
Laravel’s default API throttle — 60 requests per minute — is a reasonable floor, not a number that fits every endpoint or every client. A blanket limit either blocks legitimate high-frequency clients (a mobile app doing normal polling) or lets abusive clients through at a rate that’s actually damaging, because one number was asked to do two jobs.
// app/Providers/AppServiceProvider.php
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
return match (true) {
$user?->plan === 'enterprise' => Limit::perMinute(1000)->by($user->id),
$user?->plan === 'pro' => Limit::perMinute(300)->by($user->id),
$user !== null => Limit::perMinute(60)->by($user->id),
default => Limit::perMinute(20)->by($request->ip()), // unauthenticated
};
});
// Different limiters for different endpoint sensitivity — login attempts
// deserve a much stricter limit than general read traffic
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('posts', PostController::class);
});
Plan-tier rate limiting via ->by($user->id) — scoped per authenticated user rather than a single global bucket — is what allows an enterprise customer’s legitimate traffic pattern to not get throttled at the same threshold as an anonymous IP making unauthenticated requests. The login-specific limiter, kept deliberately strict and separate from general API traffic, is a real defense against credential-stuffing attempts without affecting the rate limit any authenticated user experiences on normal endpoints.
// The response headers Laravel adds automatically — worth surfacing to
// API consumers in documentation, not just letting them discover by trial and error
// X-RateLimit-Limit: 300
// X-RateLimit-Remaining: 247
// Retry-After: 42 (only present once the limit is actually hit)
Pagination That Survives Real Data Volume
Offset-based pagination (paginate()) is the right default and works fine for the overwhelming majority of API use cases — but it has a specific, well-known failure mode worth knowing about before it causes a production issue: OFFSET 500000 LIMIT 20 on a large table means the database still has to scan and discard the first 500,000 rows before returning the 20 that matter, and that cost grows linearly with how deep into the dataset a client pages.
// Standard offset pagination — fine for the common case
Post::latest()->paginate(20);
// Cursor pagination — for endpoints where deep pagination on a large
// table is a realistic access pattern (an infinite-scroll feed, an
// export tool paging through the entire dataset)
Post::latest()->cursorPaginate(20);
{
"data": [...],
"links": {
"next": "https://api.example.com/v1/posts?cursor=eyJpZCI6MTIzfQ"
}
}
Cursor pagination encodes a pointer to the last-seen row directly into the next link, rather than an offset count — the database query becomes WHERE id > ? LIMIT 20 (using whatever the cursor points to), which is a fast indexed lookup regardless of how deep into the dataset the client has paged, instead of a scan-and-discard operation that gets slower the further in a client goes. The tradeoff is that cursor pagination doesn’t support jumping to an arbitrary page number — only forward and backward from the current position — which is a real UX constraint for a numbered-page-links interface, and exactly why it’s worth choosing per-endpoint based on actual access pattern rather than switching every endpoint to cursor pagination by default.
Testing With Pest — Proving the Contract, Not Just the Status Code
An API test that only checks assertStatus(200) verifies the server didn’t crash. It says nothing about whether the response actually has the shape a frontend team is relying on — which is exactly the gap that lets a response shape drift silently over months of small changes until a consuming client breaks on a field that quietly disappeared.
// tests/Feature/Api/PostControllerTest.php
it('returns a paginated list of posts with the expected shape', function () {
Post::factory()->count(25)->create();
$response = $this->getJson('/api/v1/posts');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'body', 'published_at', 'author'],
],
'links' => ['first', 'last', 'prev', 'next'],
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
])
->assertJsonCount(20, 'data'); // default page size, not all 25
});
it('rejects an unauthenticated request to create a post', function () {
$this->postJson('/api/v1/posts', ['title' => 'New Post'])
->assertStatus(401)
->assertJson(['message' => 'Unauthenticated.']);
});
it('returns validation errors in the standard envelope', function () {
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/v1/posts', ['title' => ''])
->assertStatus(422)
->assertJsonStructure(['message', 'errors' => ['title']]);
});
it('respects the login rate limiter', function () {
$user = User::factory()->create();
for ($i = 0; $i < 5; $i++) {
$this->postJson('/api/login', ['email' => $user->email, 'password' => 'wrong']);
}
$this->postJson('/api/login', ['email' => $user->email, 'password' => 'wrong'])
->assertStatus(429);
});
it('does not trigger an N+1 query when listing posts with authors', function () {
Post::factory()->count(10)->create();
DB::enableQueryLog();
$this->getJson('/api/v1/posts');
expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(4);
});
Sanctum::actingAs() is the pattern that keeps auth-dependent tests fast and independent of the actual login flow — it authenticates a test request as a given user directly, without hitting the real /login endpoint and generating a real token for every single test that needs an authenticated user. The rate-limit test and the query-count test are both examples of testing a behavioral contract rather than just a status code — a 429 after exactly five failed attempts, and a bounded query count regardless of how many posts exist — which is the level of test that actually catches a regression before a consuming client does.
The Complete Shape
routes/
api.php -- version prefix group only
api_v1.php -- actual v1 routes
app/Http/
Controllers/Api/V1/ -- versioned, separate from V2 when it exists
Requests/ -- Form Requests for all validation
Resources/ -- explicit allowlist for every response shape
app/Providers/AppServiceProvider.php -- named rate limiters, per plan tier
bootstrap/app.php -- ->withExceptions(), one envelope for every failure
tests/Feature/Api/ -- shape assertions, auth assertions, rate-limit
assertions, query-count assertions — not just
status-code checks
The One Rule
Every pattern in this post exists to answer the same question a consuming frontend team will eventually ask, whether or not anyone thought to answer it upfront: is this endpoint going to behave the same way as every other endpoint in this API? Same response shape, same error envelope, same pagination metadata, same versioning discipline, same rate-limit headers. The three weeks spent retroactively standardizing an inherited API were never actually about fixing broken code — every endpoint in that codebase worked. They were about paying, after the fact and all at once, for consistency that costs almost nothing to establish upfront and costs real weeks to retrofit once several endpoints have already drifted in their own directions.
