Laravel API Development Reference

A Laravel API development reference: setting up Sanctum, issuing API tokens, and building JSON API resource controllers.

Installing the API scaffolding

Since Laravel 11, a fresh project has no API routes file by default. This one command adds Sanctum, publishes its config/migration, and creates routes/api.php wired up in bootstrap/app.php — see the routing guide for how withRouting() registers route files.

php artisan install:api

Enable token issuing on the User model:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasFactory, Notifiable, HasApiTokens;
}
php artisan migrate

Issuing tokens

Sanctum tokens are simple, database-backed API keys — no OAuth server needed, and notably not JWTs: the token itself is an opaque random string with nothing encoded inside it, unlike a JWT you'd inspect with a JWT decoder. Typically issued right after a normal login, and given straight back to the client to store and send on future requests. The validate() call below follows the same rules covered in the validation rules guide.

// e.g. in an API login controller
public function login(Request $request)
{
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    return response()->json([
        'token' => $user->createToken('mobile-app')->plainTextToken,
    ]);
}

The plain-text token is only ever shown once, at creation time — only its hash is stored in the personal_access_tokens table.

Token abilities (scopes)

// Restrict what this token is allowed to do
$token = $user->createToken('read-only', ['posts:read'])->plainTextToken;

// In a route or policy
if ($request->user()->tokenCan('posts:read')) {
    // ...
}

SPA authentication

Bearer tokens are for third-party clients (a mobile app, a server-to-server integration). For a first-party single-page app served from your own frontend, Sanctum has a second, cookie-based mode that needs no token handling in JavaScript at all — the SPA just authenticates via the normal session-based login and Sanctum issues an encrypted, httpOnly session cookie instead.

// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')),

The SPA must first hit GET /sanctum/csrf-cookie before logging in (to receive a CSRF token), then submit the login form as normal — from that point, every request from the SPA's domain is authenticated via the session cookie, and auth:sanctum middleware accepts either that cookie or a bearer token transparently.

Protecting API routes

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $request) => $request->user());
    Route::apiResource('posts', Api\PostController::class);
});

The client sends the token on every request as a bearer token:

Authorization: Bearer 1|f8b3c9d2e1a4...

Revoking tokens

// Revoke the token used for the current request (e.g. logout)
$request->user()->currentAccessToken()->delete();

// Revoke every token for a user
$user->tokens()->delete();

API resource controllers

apiResource registers the same seven conventional actions as resource, minus the two that only make sense for an HTML form (create and edit), since an API client doesn't need a route that just returns a form.

php artisan make:controller Api/PostController --api --model=Post
Route::apiResource('posts', Api\PostController::class);
VerbURIAction
GET/postsindex
POST/postsstore
GET/posts/{post}show
PUT/PATCH/posts/{post}update
DELETE/posts/{post}destroy

Shaping JSON with API resources

An Eloquent model serialised directly to JSON exposes every column, in whatever shape the database happens to use. A resource class controls exactly what the API returns instead.

php artisan make:resource PostResource
class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'excerpt' => Str::limit($this->body, 100),
            'author' => $this->whenLoaded('user', fn () => $this->user->name),
            'published_at' => $this->published_at?->toIso8601String(),
        ];
    }
}
public function show(Post $post)
{
    return new PostResource($post);
}

public function index()
{
    return PostResource::collection(Post::with('user')->paginate());
}

Wrapping a paginator in ::collection() automatically adds meta (current page, total, per-page count) and links (first/last/prev/next URLs) alongside the data array — the client doesn't need a separate call to know how many pages exist. ->additional(['version' => 'v1']) merges extra top-level keys alongside data for anything else the response needs to carry. Besides whenLoaded, a resource can conditionally include a field with when($condition, $value), merge in a set of keys at once with mergeWhen(), or return null only when a value genuinely isn't null with whenNotNull().

Error responses

When a request's Accept header asks for JSON (which every Sanctum-protected API request typically does), Laravel automatically renders validation failures and uncaught exceptions as JSON instead of an HTML error page — a failed $request->validate() call returns a 422 with an errors object keyed by field name, with no extra code required.

{
    "message": "The email field is required.",
    "errors": {
        "email": ["The email field is required."]
    }
}

To customise the shape of other exceptions for API consumers, override render() in bootstrap/app.php's exception handling:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ModelNotFoundException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json(['message' => 'Resource not found.'], 404);
        }
    });
})

Rate limiting

API routes get a named api throttle by default (60 requests/minute per user or IP). Define your own limiter for finer control; rate limiter state is stored in whichever cache store the app is configured to use.

// bootstrap/app.php or a service provider
RateLimiter::for('uploads', function (Request $request) {
    return Limit::perMinute(10)->by($request->user()->id);
});

Route::middleware('throttle:uploads')->post('/uploads', ...);