Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

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

Client Vue / mobile throttle:api 429 if over auth:sanctum 401 if no token FormRequest 403 / 422 Controller with(...) Resource toArray() 200 {"data": {...}, "links": {...}, "meta": {...}} Authorization: GET /api/v1/projects Bearer 1|xYz…

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:

  1. 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.
  2. API tokens: for mobile apps, CLIs and third parties. Each token is stored hashed in personal_access_tokens and 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}/complete is fine for actions that aren't plain CRUD, but prefer PATCH /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: PUT and DELETE should be safe to retry. For POST payments, accept an Idempotency-Key header.
  • Document it: generate OpenAPI docs (Scribe, Scramble) from your Form Requests and Resources.

Key takeaways

  • php artisan install:api adds routes/api.php and Sanctum. Version your routes (/api/v1) and use apiResource.
  • API Resources define the public JSON contract. Use whenLoaded, whenCounted and when to keep responses lean and N+1-free.
  • Sanctum handles both cookie-based SPA auth and hashed, scoped personal access tokens.
  • Protect endpoints with named RateLimiter definitions 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.