Navod Hansajith
All posts

Build a REST API with Laravel Sanctum

17 October 2026, 6 min read, Laravel, REST API, Sanctum

Laravel Sanctum issues API tokens for mobile apps, SPAs and other clients. This guide builds a small task API from a fresh Laravel 11 or 12 project:

  • register, log in and log out with tokens
  • a protected CRUD endpoint for tasks
  • validation, JSON resources, rate limits and tests

1. Create the project and install Sanctum

composer create-project laravel/laravel task-api
cd task-api
php artisan install:api

install:api installs Sanctum, creates routes/api.php and adds the personal_access_tokens migration. Answer yes when it asks to run migrations.

Add the HasApiTokens trait to the user model:

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

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

    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }
}

Keep the rest of the generated model as it is: the $fillable, $hidden and casts() entries. The 'password' => 'hashed' cast means passwords are hashed automatically when you save them.

2. Register, log in and log out

php artisan make:controller Api/AuthController
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ]);

        $user = User::create($data);

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

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

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

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

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

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

        return response()->noContent();
    }
}

The login method checks the password with Hash::check rather than Auth::attempt. API routes are stateless and have no session, and Auth::attempt tries to write to one.

device_name labels each token, such as "Pixel 8" or "Admin dashboard", so a user can see and revoke tokens per device later.

3. The task model

php artisan make:model Task -m

The migration:

Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->boolean('completed')->default(false);
    $table->timestamps();
});

The model:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Task extends Model
{
    protected $fillable = ['title', 'completed'];

    protected function casts(): array
    {
        return ['completed' => 'boolean'];
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
php artisan migrate

4. Validation and JSON output

A form request keeps validation out of the controller:

php artisan make:request TaskRequest
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class TaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'completed' => ['sometimes', 'boolean'],
        ];
    }
}

The generated authorize() returns false, so change it to true or every request will get a 403.

An API resource controls exactly which fields leave your API:

php artisan make:resource TaskResource
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'completed' => $this->completed,
        'created_at' => $this->created_at,
    ];
}

5. The task controller

php artisan make:controller Api/TaskController --api --model=Task
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\TaskRequest;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function index(Request $request)
    {
        return TaskResource::collection(
            $request->user()->tasks()->latest()->paginate(15)
        );
    }

    public function store(TaskRequest $request)
    {
        $task = $request->user()->tasks()->create($request->validated());

        return new TaskResource($task);
    }

    public function show(Request $request, Task $task)
    {
        abort_if($request->user()->isNot($task->user), 404);

        return new TaskResource($task);
    }

    public function update(TaskRequest $request, Task $task)
    {
        abort_if($request->user()->isNot($task->user), 404);

        $task->update($request->validated());

        return new TaskResource($task);
    }

    public function destroy(Request $request, Task $task)
    {
        abort_if($request->user()->isNot($task->user), 404);

        $task->delete();

        return response()->noContent();
    }
}

Each action checks that the task belongs to the current user. Returning 404 instead of 403 means the API does not reveal that another user's task exists. When a resource wraps a model that was just created, Laravel responds with 201 Created on its own.

As the API grows, move these ownership checks into a policy (php artisan make:policy TaskPolicy --model=Task).

6. Routes and rate limits

Replace the contents of routes/api.php:

use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\TaskController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

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

Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
    Route::get('/me', fn (Request $request) => $request->user());
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::apiResource('tasks', TaskController::class);
});

Every route in this file is prefixed with /api. throttle:5,1 allows five login attempts per minute from one client, which slows down password guessing.

7. Try it with curl

php artisan serve

Register a user:

curl -X POST http://localhost:8000/api/register \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name":"Test User","email":"[email protected]","password":"secret123","password_confirmation":"secret123"}'

The response includes a token such as 1|Xb2.... Send it as a bearer token:

curl -X POST http://localhost:8000/api/tasks \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer 1|Xb2..." \
  -d '{"title":"Write API docs"}'

curl http://localhost:8000/api/tasks \
  -H "Accept: application/json" \
  -H "Authorization: Bearer 1|Xb2..."

Always send Accept: application/json. Without it, a failed validation redirects like a web form instead of returning a 422 with the errors as JSON.

8. Limit what a token can do

Tokens can carry abilities. A read-only token for a reporting tool:

$token = $user->createToken('reports', ['tasks:read'])->plainTextToken;

Register Sanctum's ability middleware in bootstrap/app.php:

use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;

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

Then split the task routes so that writing needs the tasks:write ability:

Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
    Route::get('/me', fn (Request $request) => $request->user());
    Route::post('/logout', [AuthController::class, 'logout']);

    Route::apiResource('tasks', TaskController::class)->only(['index', 'show']);
    Route::apiResource('tasks', TaskController::class)
        ->except(['index', 'show'])
        ->middleware('abilities:tasks:write');
});

Tokens created without a list of abilities, like the ones from login, get every ability, so existing clients keep working. Inside a controller you can also check $request->user()->tokenCan('tasks:write').

9. Expire old tokens

Tokens never expire by default. Set a lifetime in minutes in config/sanctum.php:

'expiration' => 60 * 24 * 30,

If the file is missing, publish it first:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Expired tokens stay in the database until you prune them. Schedule that in routes/console.php:

use Illuminate\Support\Facades\Schedule;

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

10. Test the API

Sanctum::actingAs authenticates a test user without creating a real token:

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

it('creates a task for the current user', function () {
    $user = User::factory()->create();
    Sanctum::actingAs($user, ['tasks:write']);

    $this->postJson('/api/tasks', ['title' => 'Ship v1'])
        ->assertCreated()
        ->assertJsonPath('data.title', 'Ship v1');

    expect($user->tasks()->count())->toBe(1);
});

it('hides other users tasks', function () {
    $task = User::factory()->create()->tasks()->create(['title' => 'Private']);
    Sanctum::actingAs(User::factory()->create());

    $this->getJson("/api/tasks/{$task->id}")->assertNotFound();
});

it('rejects requests without a token', function () {
    $this->getJson('/api/tasks')->assertUnauthorized();
});

The second argument to actingAs lists the token's abilities, so the first test also passes the abilities:tasks:write check from step 8. These tests need a fresh database for each run. Make sure RefreshDatabase is enabled in tests/Pest.php.

Before going live

  • Serve the API over HTTPS only. A bearer token is as good as a password.
  • Store tokens in secure storage on mobile (Keychain, Android Keystore). Never put them in plain local storage.
  • Give tokens an expiry and only the abilities each client needs.
  • If a browser front end on another domain calls the API, run php artisan config:publish cors and set allowed_origins to that domain.
  • Never log full request headers. The Authorization header contains the token.

Need help with a Laravel project?

I am available for freelance work.

Get a quote