Building REST APIs with Laravel Sanctum

Your Laravel app works fine in the browser, and now the mobile team wants endpoints for login and product data. Session cookies do not suit native Android or iOS clients, and a ful...

Building REST APIs with Laravel Sanctum

Your Laravel app works fine in the browser, and now the mobile team wants endpoints for login and product data. Session cookies do not suit native Android or iOS clients, and a full OAuth2 server is overkill for a first-party app. Laravel Sanctum fills exactly that gap.

This tutorial builds a Laravel 11 REST API from scratch: register, login, logout, protected routes, token abilities, rate limiting, and consistent JSON output. Each step explains the reasoning, not just the code.

When Sanctum Fits and When It Does Not

Sanctum has two modes. API tokens issue random tokens stored in the personal_access_tokens table, ideal for mobile apps and server integrations. SPA authentication uses session cookies for a React or Vue frontend on the same top-level domain. We focus on tokens here.

NeedGood fitWhy
Your own mobile appSanctum tokensLightweight, revocable per device
SPA on the same domainSanctum cookiesCSRF protected, no token in JavaScript
Third parties acting for your usersPassport (OAuth2)Needs authorization code flow and registered clients
Internal service-to-service callsSanctum token with narrow abilitiesOne token per service is enough

Still deciding on the API style? Read REST API vs GraphQL first.

Step 1: Install

Laravel 11 no longer ships routes/api.php by default. One command sets everything up:

php artisan install:api
php artisan migrate

It installs laravel/sanctum, creates the API routes file, registers it in bootstrap/app.php, and publishes the token migration. Then add the HasApiTokens trait to your User model, which is not done for you:

use Laravel\Sanctum\HasApiTokens;

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

Step 2: Register, Login, Logout

public function login(Request $request)
{
    $data = $request->validate([
        'email'       => 'required|email',
        'password'    => 'required',
        'device_name' => 'required|string|max:100',
    ]);

    $user = User::where('email', $data['email'])->first();

    if (! $user || ! Hash::check($data['password'], $user->password)) {
        return response()->json(['message' => 'Invalid credentials.'], 401);
    }

    $abilities = $user->is_admin ? ['*'] : ['product:read'];
    $token = $user->createToken($data['device_name'], $abilities, now()->addDays(30));

    return response()->json(['token' => $token->plainTextToken]);
}

public function logout(Request $request)
{
    $request->user()->currentAccessToken()->delete();

    return response()->json(['message' => 'Logged out.']);
}

The register method follows the same pattern: validate, create the user with Hash::make(), issue a token, and return status 201. A few design choices matter here:

  • One generic error message for unknown email and wrong password, so attackers cannot enumerate accounts.
  • device_name becomes the token name. If a user loses a phone, you revoke that token only.
  • The third argument to createToken() sets a per-token expiry. The is_admin column is just an assumption for the example.
  • Sanctum stores a SHA-256 hash, so the plain token appears only once in the response. The client must store it.

Step 3: Routes and Protection

Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:5,1');
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:5,1');

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $request) => $request->user());
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/products', [ProductController::class, 'index']);
    Route::post('/products', [ProductController::class, 'store'])->middleware('abilities:product:write');
});

throttle:5,1 allows five attempts per minute, which is fine for humans and painful for brute-force scripts. Register the abilities middleware alias in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
        'ability'   => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
    ]);
})

Step 4: Clean JSON with API Resources

Returning raw models means every new database column leaks to clients. API Resources decouple your schema from your API contract:

public function toArray(Request $request): array
{
    return [
        'id'         => $this->id,
        'name'       => $this->name,
        'price'      => (int) $this->price,
        'created_at' => $this->created_at->toDateString(),
    ];
}

Return ProductResource::collection(Product::latest()->paginate(10)) and clients get links and meta blocks for pagination. When listing endpoints load relations, eager load them; see optimizing Eloquent query performance.

Step 5: Always Return JSON Errors

A client that forgets Accept: application/json may get an HTML page or a redirect to /login. Force JSON for the API in bootstrap/app.php:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->shouldRenderJsonWhen(
        fn (Request $request, Throwable $e) => $request->is('api/*') || $request->expectsJson()
    );
})

Step 6: Test with cURL

curl -X POST http://localhost:8000/api/login \
  -H "Accept: application/json" \
  -d "email=jane@example.com&password=secret123&device_name=postman"

curl http://localhost:8000/api/user \
  -H "Accept: application/json" \
  -H "Authorization: Bearer 1|xxxxxxxxxxxxxxxx"

Step 7: Lock Behavior with Feature Tests

Clicking through Postman after every change gets tedious, so it eventually stops happening. Feature tests replay the same scenarios in seconds. Sanctum::actingAs() authenticates a user with specific abilities without a real login:

use App\Models\User;
use Laravel\Sanctum\Sanctum;

public function test_guest_cannot_list_products(): void
{
    $this->getJson('/api/products')->assertUnauthorized();
}

public function test_read_token_cannot_create_products(): void
{
    Sanctum::actingAs(User::factory()->create(), ['product:read']);

    $this->postJson('/api/products', ['name' => 'Coffee'])->assertForbidden();
}

These two tests guard the failures that hurt most: a route that silently opens to anonymous users, and a read-only token that can write data.

Plan for API Versions

Once your mobile app is installed on thousands of phones, you cannot force everyone to update on the same day. Add a version prefix from the start, for example Route::prefix('v1'). When the response format must change in a breaking way, ship v2 and keep v1 alive for a few months while you watch its traffic in the logs. Additive changes, such as a new field, do not need a new version because older clients simply ignore it.

Where to store the token on the client

On mobile, keep the token in the platform's secure storage: Keychain on iOS and the Android Keystore (or EncryptedSharedPreferences). Avoid plain preferences files or logs. If you build a browser SPA instead, switch to Sanctum's cookie mode rather than storing bearer tokens in localStorage, where any injected script could read them.

Common Errors and Fixes

SymptomCauseFix
Call to undefined method createToken()HasApiTokens trait missingAdd the trait to User
"Route [login] not defined"Unauthenticated request without Accept header gets redirectedSend the header or use shouldRenderJsonWhen
404 on /api/...install:api never ranRun it and check php artisan route:list
Always 401 with a valid tokenWeb server strips the Authorization headerKeep Laravel's default public .htaccess or pass the header in your server config
Token table keeps growingExpired tokens never removedSchedule sanctum:prune-expired
use Illuminate\Support\Facades\Schedule;

Schedule::command('sanctum:prune-expired --hours=24')->daily();

Production Checklist

  • HTTPS everywhere, no exceptions.
  • Throttling on login and register.
  • Tokens named per device, with an expiry.
  • Abilities kept as narrow as each role allows.
  • Every response goes through an API Resource.
  • 401, 403, 404, and 422 errors always come back as JSON.
  • Feature tests cover failed login, successful login, and access without a token.

For a broader security baseline, see 10 must-have web application security practices. With this structure your API stays predictable as clients and endpoints multiply.

Yudhi
Written by
Yudhi
Founder & Lead Developer, GudangCode

Yudhi is the founder of GudangCode and a Laravel developer who has built dozens of ready-to-use business information systems — from POS and HRIS to management apps. He writes guides and articles on GudangCode to help Indonesian developers run, understand, and deploy Laravel source code correctly.

LaravelPHPMySQLSistem Informasi Bisnis See all articles by Yudhi
Want the full source code & apps?

Sign up free to download ready-to-use business applications, information systems, and Laravel source code.

Sign Up Free & Download
Laravel Sanctum Api Programming & Laravel
Share this article
Back to Blog
📚 Free Learning Hub

Learn Coding for Free at DhieCoderWeb

Explore Laravel, PHP, JavaScript tutorials, source code, web development guides, and practical programming tips.

DhieCoderWeb
100+
Tutorials
Free
Learning
SEO
Tips
Visit Dhiecoderweb.com →

Get Full Access Now!

Join our membership and unlock exclusive access to all premium features. Fast, easy, and ready to use instantly.

Join Membership Now