Building APIs with Sanctum
API routes, ability-scoped tokens, API resources, pagination, rate limiting and consistent error responses for a JSON API.
Tokens and abilities
// issue a token at login
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages(['email' => 'Those credentials do not match.']);
}
$token = $user->createToken(
name: 'mobile',
abilities: ['books:read', 'books:write'],
expiresAt: now()->addDays(30),
)->plainTextToken;
return response()->json(['token' => $token]);
// protect routes
Route::middleware('auth:sanctum')->group(function () {
Route::get('/books', [BookController::class, 'index']);
Route::delete('/books/{book}', [BookController::class, 'destroy'])
->middleware('abilities:books:write');
});- Sanctum checks the token's abilities, not the user's roles. Combine both: abilities scope the token, policies scope the record.
- For a first-party SPA, use cookie-based SPA authentication instead of tokens - it avoids storing credentials in JavaScript.
expiresAtgives you a bounded window; prune expired tokens with the scheduledsanctum:prune-expiredcommand.
⚠️
plainTextToken is shown exactly once, at creation. Store only the hash of the token server-side and treat a token like a password - never in a URL, never in a log line, never in a mobile analytics payload.Resources and pagination
class BookResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => new AuthorResource($this->whenLoaded('author')),
'reviews_count' => $this->whenCounted('reviews'),
'links' => [
'self' => route('api.books.show', $this->id),
],
];
}
}
class BookCollection extends ResourceCollection
{
public $collects = BookResource::class;
public function toArray(Request $request): array
{
return ['data' => $this->collection];
}
}
return new BookCollection(Book::with('author')->paginate(25));| Concern | Approach | Why |
|---|---|---|
| Pagination | paginate() or cursorPaginate() | Cursor pagination stays stable while rows are inserted |
| Conditional fields | whenLoaded / whenCounted | Avoids lazy loading during serialisation |
| Relations | with() in the query | One query per relation, not per row |
| Errors | render() to a JSON shape | One envelope for validation, auth and domain errors |
Route::middleware('throttle:api')->group(function () {
Route::apiResource('books', BookController::class);
});
// per-endpoint limit
Route::post('/books', StoreBook::class)->middleware('throttle:20,1');A consistent error shape
// bootstrap/app.php (Laravel 11+)
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ValidationException $e, Request $request) {
if (! $request->expectsJson()) return null;
return response()->json([
'error' => ['code' => 'validation_failed', 'message' => 'The given data was invalid.',
'fields' => $e->errors()],
], 422);
});
})
// a domain exception renders itself
class InsufficientStock extends DomainException
{
public function render(Request $request): JsonResponse
{
return response()->json([
'error' => ['code' => 'insufficient_stock', 'message' => $this->getMessage()],
], 409);
}
}Clients parse errors more reliably when the shape never changes. Decide on one envelope - error.code, error.message, optional error.fields - and keep 400, 401, 403, 404, 409 and 422 meaning exactly one thing each.
FAQ
Sanctum tokens or OAuth?
Tokens for first-party clients you control. OAuth with scopes, refresh tokens and client registration when third parties integrate - that is Passport territory, and the extra machinery only pays off with external consumers.
Why does my API return an HTML error page?
The request did not advertise JSON. Send
Accept: application/json, or call $request->expectsJson() in your exception renderer so a missing header still produces a JSON envelope.Related
Authentication and authorization Testing with Pest and PHPUnit
Last refreshed 2026-09-18.