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 corsand setallowed_originsto that domain. - Never log full request headers. The
Authorizationheader contains the token.