Building APIs
API routes, resources, Sanctum tokens and rate limiting.
Most modern Laravel apps serve JSON as well as HTML: to a Vue or React front end, a mobile app, or other services. Laravel gives you the full toolkit for this. There are API routes, API Resources for shaping responses, Sanctum for token and SPA authentication, and rate limiting to protect the whole thing.
In this lesson we expose the Projects & Tasks app as a versioned REST API.
Anatomy of an API request
That's the full pipeline for an authenticated API. This app's public demo API runs a subset of it: throttle:demo → AddQueryCountHeader → controller (validating its filters) → API Resource. There's no token step.
API routes
API routes aren't installed by default in a fresh Laravel 11+ app. The usual way to add them is:
php artisan install:api # creates routes/api.php, installs Sanctum, publishes its migration
This app's demo API is public and needs no tokens, so it skips Sanctum. It just creates routes/api.php and registers it with api: __DIR__.'/../routes/api.php' in bootstrap/app.php's withRouting().
Routes in routes/api.php are automatically prefixed with /api and run in the stateless api middleware group: no sessions, no CSRF. This is this app's real API file, a public, read-only demo of the Projects & Tasks data:
routes/api.php
// Public, read-only "Projects & Tasks" API used by the tutorials' live demos
Route::prefix('demo')
->name('api.demo.')
->middleware(['throttle:demo', AddQueryCountHeader::class])
->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::get('/tasks', [TaskController::class, 'index'])->name('tasks.index');
Route::post('/tasks/validate', [TaskController::class, 'validateOnly'])->name('tasks.validate');
Route::get('/query-count', QueryCountController::class)->name('query-count');
});
{project} resolves by slug because Project::getRouteKeyName() returns 'slug', so the URL is /api/demo/projects/website-relaunch, not /api/demo/projects/1. Try it: /api/demo/projects · /api/demo/tasks?priority=high&status=open.
Illustrative — not in this app: for a public API with writes, version it from day one, because changing a response shape later breaks every client:
Route::prefix('v1')->middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('projects', V1\ProjectController::class);
Route::apiResource('projects.tasks', V1\TaskController::class)->shallow();
});
apiResource registers the five JSON actions (index, store, show, update, destroy) and leaves out create and edit, because those are HTML form pages. ->shallow() gives /projects/{project}/tasks for listing and creating, and /tasks/{task} for everything else.
API Resources: shaping the JSON
Returning a model straight from a controller works, but it dumps every column, including ones you never meant to expose. It also couples your API contract to your database schema. An API Resource is a transformation layer.
php artisan make:resource TaskResource
php artisan make:resource ProjectResource
These are the real resources behind the demo API (imports trimmed):
app/Http/Resources/TaskResource.php
namespace App\Http\Resources;
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'priority' => $this->priority->value, // enum → "high"
'priority_label' => $this->priority->label(), // "High"
'due_on' => $this->due_on?->toDateString(),
'completed' => $this->isComplete(),
'completed_at' => $this->completed_at?->toIso8601String(),
'is_overdue' => $this->is_overdue, // accessor on the model
// Only included when the controller eager-loaded them: no N+1
'assignee' => $this->whenLoaded('assignee', fn () => $this->assignee?->only(['id', 'name'])),
'project' => $this->whenLoaded('project', fn () => $this->project->only(['id', 'name', 'slug'])),
'tags' => TagResource::collection($this->whenLoaded('tags')),
];
}
}
app/Http/Resources/ProjectResource.php
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'owner' => $this->whenLoaded('owner', fn () => $this->owner->name),
'tasks_count' => $this->whenCounted('tasks'),
'open_tasks_count' => $this->whenCounted('open_tasks'), // from withCount('tasks as open_tasks_count')
'tasks' => TaskResource::collection($this->whenLoaded('tasks')),
'url' => route('api.demo.projects.show', $this->resource),
];
}
}
Notice there's no user_id, created_at or other internal column in the output. The resource is the contract. You can also include fields conditionally per user (illustrative), for example 'internal_notes' => $this->when($request->user()?->can('update', $this->resource), $this->internal_notes).
Why use API Resources instead of $model->toArray()? They decouple the public contract from the schema, so you can rename a column without breaking clients. They control exactly what's exposed, so no password or remember_token leaks. They support conditional fields (when, whenLoaded, whenCounted). And they keep controllers clean. Pair whenLoaded with eager loading in the controller and the resource can never trigger N+1 queries.
Try it: the resource output
This is the real ProjectResource collection. Compare the fields with the toArray() method above. owner and open_tasks_count appear because the controller loaded them, and nothing else from the table leaks out:
The controller
The real Api\TaskController::index() validates its query-string filters like any other input, then composes the query with when() and the model's scopes:
app/Http/Controllers/Api/TaskController.php
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate([
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'status' => ['nullable', Rule::in(['open', 'completed'])],
'project' => ['nullable', 'string', 'exists:projects,slug'],
]);
$tasks = Task::query()
->with(['project', 'assignee', 'tags'])
->when($filters['priority'] ?? null, fn ($query, string $priority) => $query->priority(TaskPriority::from($priority)))
->when(($filters['status'] ?? null) === 'open', fn ($query) => $query->open())
->when(($filters['status'] ?? null) === 'completed', fn ($query) => $query->completed())
->when($filters['project'] ?? null, fn ($query, string $slug) => $query->whereRelation('project', 'slug', $slug))
->when($filters['search'] ?? null, fn ($query, string $search) => $query->whereLike('title', "%{$search}%"))
->orderBy('id')
->paginate(10)
->withQueryString();
return TaskResource::collection($tasks);
}
And Api\ProjectController shows counts plus eager loading, including a nested, ordered relation:
app/Http/Controllers/Api/ProjectController.php
public function index(): AnonymousResourceCollection
{
$projects = Project::query()
->with('owner')
->withCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()])
->orderBy('name')
->get();
return ProjectResource::collection($projects);
}
public function show(Project $project): ProjectResource
{
$project->load(['owner', 'tasks' => fn ($query) => $query->orderBy('id'), 'tasks.assignee', 'tasks.tags'])
->loadCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()]);
return new ProjectResource($project);
}
Illustrative — not in this app: a full CRUD API would add store (returning new TaskResource($task), which gives a 201 automatically), update, and destroy (returning response()->noContent() for a 204), each guarded with Gate::authorize() against TaskPolicy.
Explore the live API
Pick an endpoint and see the raw JSON the resources produce, along with the headers the middleware and rate limiter add:
Never pass raw user input to orderBy() or where() column names. Notice the controller above validates every filter (Rule::enum, Rule::in, exists) before using it. Values are bound safely as parameters, but column names can't be. If you accept a sort parameter, whitelist it: Rule::in(['due_on', 'created_at', 'title']).
A paginated response from /api/demo/tasks?priority=high looks like this (real output trimmed to one task. Numeric ids vary by environment, so link to projects by slug):
{
"data": [
{
"id": 65,
"title": "Design new homepage hero",
"description": null,
"priority": "high",
"priority_label": "High",
"due_on": null,
"completed": true,
"completed_at": "2026-10-16T15:24:11+00:00",
"assignee": { "id": 51, "name": "Chloe Martin" },
"project": { "id": 21, "name": "Website Relaunch", "slug": "website-relaunch" },
"tags": [
{ "name": "Frontend", "slug": "frontend" },
{ "name": "Design", "slug": "design" }
]
}
],
"links": { "first": "…/api/demo/tasks?priority=high&page=1", "last": "…", "prev": null, "next": null },
"meta": { "current_page": 1, "from": 1, "last_page": 1, "per_page": 10, "to": 8, "total": 8, "links": ["…"] }
}
paginate() adds links and meta automatically, and ->withQueryString() keeps priority=high on every page link.
HTTP status codes cheat sheet
| Situation | Code | Laravel produces it via |
|---|---|---|
| Created | 201 | Returning a new resource from store (model wasRecentlyCreated) |
| Deleted | 204 | response()->noContent() |
| Not logged in | 401 | auth:sanctum middleware |
| Not allowed | 403 | Gate::authorize(), Form Request authorize() |
| Not found | 404 | Route model binding |
| Validation failed | 422 | Form Request / validate() |
| Rate limited | 429 | throttle middleware, with a Retry-After header |
Authentication with Sanctum
Illustrative — not in this app. The demo API is deliberately public and read-only, and Sanctum isn't installed.
Sanctum supports two modes with one guard:
- SPA authentication: your own Vue front end on the same top-level domain uses normal session cookies. Call
/sanctum/csrf-cookie, log in, and from then on requests are authenticated by cookie. There are no tokens to store in JavaScript, which removes a whole class of XSS risk. - API tokens: for mobile apps, CLIs and third parties. Each token is stored hashed in
personal_access_tokensand can carry abilities (scopes).
// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
// Issue a token (e.g. from a "Create API token" settings page)
Route::post('/tokens', function (Request $request) {
$request->validate(['name' => ['required', 'string', 'max:50']]);
$token = $request->user()->createToken(
$request->name,
abilities: ['tasks:read', 'tasks:write'],
expiresAt: now()->addDays(90),
);
return ['token' => $token->plainTextToken]; // shown ONCE, e.g. "7|Zx9k…"
})->middleware('auth');
// Check abilities inside the API
abort_unless($request->user()->tokenCan('tasks:write'), 403);
// …or as route middleware (register the alias first, see below)
Route::post('tasks', ...)->middleware(['auth:sanctum', 'abilities:tasks:write']);
The abilities (all listed) and ability (any listed) middleware aren't aliased out of the box. Register them in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
]);
})
Tokens never expire by default. Set expiration in config/sanctum.php, or pass expiresAt per token as above, and schedule sanctum:prune-expired to clean up.
curl -H "Authorization: Bearer 7|Zx9k..." \
-H "Accept: application/json" \
https://app.test/api/v1/projects
Always send Accept: application/json. Without it, a validation failure or a missing token may come back as an HTML redirect to /login instead of a 422 or 401 JSON response. API routes return JSON for exceptions, but clients should still be explicit.
Rate limiting
Define named limiters in a service provider, then attach them with throttle:{name}. The demo API's limiter is a one-liner: 120 requests a minute per IP address.
app/Providers/AppServiceProvider.php (in boot())
// Public tutorial demo API: 120 requests a minute per IP
RateLimiter::for('demo', fn (Request $request) => Limit::perMinute(120)->by($request->ip()));
A fuller setup distinguishes signed-in users from guests and gives expensive endpoints their own budget. Illustrative — not in this app:
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
// Stricter limit for expensive endpoints, with a custom response
RateLimiter::for('exports', function (Request $request) {
return Limit::perHour(5)->by($request->user()->id)
->response(fn (Request $request, array $headers) => response()->json(
['message' => 'Export limit reached. Try again later.'], 429, $headers // keep Retry-After
));
});
}
Every throttled response carries X-RateLimit-Limit and X-RateLimit-Remaining headers, and a 429 response adds Retry-After. Counters live in your cache store. With several app servers behind a load balancer, that store must be shared (Redis, database), otherwise each server counts separately.
The DemoApiTest::test_demo_api_is_rate_limited test fires 120 requests and asserts the 121st gets assertTooManyRequests(). Rather than make you burn through the real limit, the simulator below runs the same algorithm with Limit::perMinute(10) and a fixed one-minute window, which is how Laravel's limiter works:
Errors and exception rendering
This app's bootstrap/app.php makes sure every API route answers in JSON, even if a client forgets the Accept header:
bootstrap/app.php
->withExceptions(function (Exceptions $exceptions): void {
// API routes always answer with JSON (422 errors, 404s), even without an Accept header
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson()
);
})
DemoApiTest::test_unknown_project_slug_returns_json_404 proves it: a plain GET /api/demo/projects/does-not-exist returns a 404 with Content-Type: application/json. Send it and see for yourself:
You can go further and customise the body per exception type. Illustrative — not in this app:
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json(['message' => 'Resource not found.'], 404);
}
});
Designing a good API
- Nouns and HTTP verbs:
POST /tasks/{id}/completeis fine for actions that aren't plain CRUD, but preferPATCH /tasks/{id}with{"status": "done"}when it's simply an update. - Consistent envelopes: always
{ "data": ... }. Resources do this for you. - Pagination by default on every list. Use
cursorPaginate()for infinite scroll over huge tables, because offset pagination slows down as page numbers grow. - Filtering via query strings, validated like any other input.
- Idempotency:
PUTandDELETEshould be safe to retry. ForPOSTpayments, accept anIdempotency-Keyheader. - Document it: generate OpenAPI docs (Scribe, Scramble) from your Form Requests and Resources.
Key takeaways
php artisan install:apiaddsroutes/api.phpand Sanctum. Version your routes (/api/v1) and useapiResource.- API Resources define the public JSON contract. Use
whenLoaded,whenCountedandwhento keep responses lean and N+1-free. - Sanctum handles both cookie-based SPA auth and hashed, scoped personal access tokens.
- Protect endpoints with named
RateLimiterdefinitions backed by a shared cache store. - Return the right status codes (201, 204, 401, 403, 404, 422, 429) and always request JSON with
Accept: application/json.