Laravel API Development with Sanctum: Build Secure APIs the Right Way

Laravel Sanctum provides a simple, lightweight authentication system for SPAs, mobile applications, and token-based APIs. Unlike Laravel Passport which implements full OAuth2, Sanctum offers a streamlined approach that is easier to understand, implement, and maintain. For most API projects, Sanctum gives you everything you need without the complexity overhead.
This tutorial walks you through building a complete, secure API with Laravel Sanctum from scratch. You will learn token-based authentication, API resource transformations, rate limiting, input validation, and security best practices. Whether you are building APIs for a mobile app or a single-page application, these patterns are what production Laravel applications use. At Swift Academy in Pokhara, our Laravel course teaches these exact patterns that Nepali and international employers expect.
What Is Laravel Sanctum and Why Should You Use It?
Laravel Sanctum is a lightweight authentication package for Laravel that provides API token management and SPA authentication without the complexity of OAuth2, making it the recommended choice for most Laravel API projects in 2026.
Sanctum vs other Laravel authentication options:
| Feature | Sanctum | Passport | JWT (tymon) |
|---|---|---|---|
| Complexity | Low | High | Medium |
| Token type | Personal access tokens | OAuth2 tokens | JSON Web Tokens |
| SPA support | Built-in (cookie-based) | Manual setup | Manual setup |
| Mobile support | Token-based | Token-based | Token-based |
| Token abilities/scopes | Simple abilities | Full OAuth scopes | Custom claims |
| Token revocation | Per-token | Per-token | Blacklisting needed |
| Setup time | 10 minutes | 30+ minutes | 20 minutes |
| Official Laravel package | Yes | Yes | Third-party |
Install Sanctum in a new Laravel project:
# Create new Laravel project
composer create-project laravel/laravel sanctum-api
cd sanctum-api
# Sanctum is included by default in Laravel 11+
# For older versions:
composer require laravel/sanctum
# Publish Sanctum config and migrations
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
# Run migrations
php artisan migrate
Add the HasApiTokens trait to your User model:
<?php
// app/Models/User.php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
How Do You Implement Token-Based Authentication?
Implement token-based authentication by creating registration and login endpoints that issue Sanctum tokens, protecting routes with the sanctum middleware, and adding logout functionality that revokes tokens for complete session management.
<?php
// app/Http/Controllers/Api/AuthController.php
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\Rules\Password;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Register a new user and issue token.
*/
public function register(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
'device_name' => ['required', 'string', 'max:255'],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken(
$validated['device_name'],
['read', 'create', 'update', 'delete'] // Token abilities
);
return response()->json([
'message' => 'Registration successful',
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token->plainTextToken,
], 201);
}
/**
* Login and issue token.
*/
public function login(Request $request)
{
$validated = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
'device_name' => ['required', 'string'],
]);
$user = User::where('email', $validated['email'])->first();
if (! $user || ! Hash::check($validated['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
// Revoke previous tokens for this device (optional)
$user->tokens()->where('name', $validated['device_name'])->delete();
$token = $user->createToken(
$validated['device_name'],
['read', 'create', 'update', 'delete']
);
return response()->json([
'message' => 'Login successful',
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token->plainTextToken,
]);
}
/**
* Logout - revoke current token.
*/
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Logged out successfully',
]);
}
/**
* Logout from all devices.
*/
public function logoutAll(Request $request)
{
$request->user()->tokens()->delete();
return response()->json([
'message' => 'Logged out from all devices',
]);
}
/**
* Get authenticated user profile.
*/
public function profile(Request $request)
{
return response()->json([
'user' => $request->user(),
]);
}
}
Set up the routes:
<?php
// routes/api.php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ProjectController;
use Illuminate\Support\Facades\Route;
// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::get('/profile', [AuthController::class, 'profile']);
Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/logout-all', [AuthController::class, 'logoutAll']);
Route::apiResource('projects', ProjectController::class);
});
How Do You Build CRUD API Endpoints with Proper Validation?
Build CRUD API endpoints using Laravel's apiResource routes with dedicated Form Request classes for validation, API Resource classes for response transformation, and Policy classes for authorization to create clean, maintainable API code.
First, create the model, migration, and related classes:
php artisan make:model Project -mcrR --api
# Creates: Model, Migration, Controller (resource), Request, Resource
<?php
// database/migrations/xxxx_create_projects_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->enum('status', ['draft', 'active', 'completed', 'archived'])->default('draft');
$table->enum('priority', ['low', 'medium', 'high'])->default('medium');
$table->date('deadline')->nullable();
$table->decimal('budget', 12, 2)->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('projects');
}
};
<?php
// app/Models/Project.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Project extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title',
'description',
'status',
'priority',
'deadline',
'budget',
];
protected function casts(): array
{
return [
'deadline' => 'date',
'budget' => 'decimal:2',
];
}
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopePriority($query, string $priority)
{
return $query->where('priority', $priority);
}
}
<?php
// app/Http/Requests/StoreProjectRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'status' => ['sometimes', Rule::in(['draft', 'active', 'completed', 'archived'])],
'priority' => ['sometimes', Rule::in(['low', 'medium', 'high'])],
'deadline' => ['nullable', 'date', 'after:today'],
'budget' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
];
}
public function messages(): array
{
return [
'title.required' => 'Every project needs a title.',
'deadline.after' => 'The deadline must be a future date.',
];
}
}
<?php
// app/Http/Resources/ProjectResource.php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'deadline' => $this->deadline?->format('Y-m-d'),
'budget' => $this->budget,
'owner' => [
'id' => $this->user->id,
'name' => $this->user->name,
],
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
<?php
// app/Http/Controllers/Api/ProjectController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use Illuminate\Http\Request;
class ProjectController extends Controller
{
public function index(Request $request)
{
$projects = $request->user()
->projects()
->when($request->status, fn($q, $status) => $q->status($status))
->when($request->priority, fn($q, $priority) => $q->priority($priority))
->when($request->search, fn($q, $search) => $q->where('title', 'like', "%{$search}%"))
->orderBy($request->sort_by ?? 'created_at', $request->sort_order ?? 'desc')
->paginate($request->per_page ?? 15);
return ProjectResource::collection($projects);
}
public function store(StoreProjectRequest $request)
{
$project = $request->user()->projects()->create($request->validated());
return new ProjectResource($project);
}
public function show(Request $request, Project $project)
{
if ($project->user_id !== $request->user()->id) {
abort(403, 'You do not own this project.');
}
return new ProjectResource($project);
}
public function update(UpdateProjectRequest $request, Project $project)
{
if ($project->user_id !== $request->user()->id) {
abort(403, 'You do not own this project.');
}
$project->update($request->validated());
return new ProjectResource($project);
}
public function destroy(Request $request, Project $project)
{
if ($project->user_id !== $request->user()->id) {
abort(403, 'You do not own this project.');
}
$project->delete();
return response()->json([
'message' => 'Project deleted successfully',
]);
}
}
How Do You Implement Token Abilities and Authorization?
Implement token abilities by assigning specific permissions when creating tokens, checking abilities in middleware or controllers with the tokenCan method, and using Laravel Policies for model-level authorization that works seamlessly with Sanctum.
<?php
// Token abilities (permissions)
// When creating a token, specify what it can do:
// Full access token
$fullToken = $user->createToken('web-app', ['*']);
// Read-only token (for third-party integrations)
$readToken = $user->createToken('reporting-tool', ['read']);
// Limited token for specific operations
$limitedToken = $user->createToken('mobile-app', ['read', 'create', 'update']);
<?php
// app/Http/Middleware/CheckTokenAbility.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckTokenAbility
{
public function handle(Request $request, Closure $next, string $ability)
{
if (! $request->user()->tokenCan($ability)) {
return response()->json([
'message' => 'Token does not have the required ability: ' . $ability,
], 403);
}
return $next($request);
}
}
// Register in bootstrap/app.php (Laravel 11+)
// ->withMiddleware(function (Middleware $middleware) {
// $middleware->alias([
// 'ability' => CheckTokenAbility::class,
// ]);
// })
<?php
// Usage in routes
Route::middleware(['auth:sanctum', 'ability:read'])->group(function () {
Route::get('/projects', [ProjectController::class, 'index']);
Route::get('/projects/{project}', [ProjectController::class, 'show']);
});
Route::middleware(['auth:sanctum', 'ability:create'])->group(function () {
Route::post('/projects', [ProjectController::class, 'store']);
});
Route::middleware(['auth:sanctum', 'ability:delete'])->group(function () {
Route::delete('/projects/{project}', [ProjectController::class, 'destroy']);
});
How Do You Add Rate Limiting and Security to Your API?
Add rate limiting using Laravel's built-in RateLimiter facade to define limits per user and endpoint, implement input sanitization, add CORS configuration, and use security headers to protect your API from abuse and attacks.
<?php
// bootstrap/app.php or app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
// Define rate limits
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
RateLimiter::for('uploads', function (Request $request) {
return Limit::perMinute(10)->by($request->user()->id);
});
<?php
// config/cors.php
return [
'paths' => ['api/*'],
'allowed_methods' => ['*'],
'allowed_origins' => [
'https://yourfrontend.com',
'http://localhost:3000', // Development
],
'allowed_headers' => ['*'],
'exposed_headers' => ['X-RateLimit-Remaining'],
'max_age' => 0,
'supports_credentials' => true,
];
API security checklist for production:
| Security Measure | Implementation | Priority |
|---|---|---|
| HTTPS enforcement | Force SSL in production | Critical |
| Rate limiting | RateLimiter per endpoint | Critical |
| Input validation | Form Request classes | Critical |
| SQL injection prevention | Eloquent ORM (parameterized queries) | Critical |
| CORS configuration | Allow specific origins only | High |
| Token expiration | Set token expiry in config | High |
| Request logging | Log API requests for auditing | Medium |
| API versioning | URL prefix /api/v1/ |
Medium |
How Do You Test Your Laravel API?
Test your Laravel API using PHPUnit with Laravel's built-in testing helpers, including actingAs for authentication, assertJson for response validation, and database factories for generating test data efficiently.
<?php
// tests/Feature/Api/ProjectTest.php
namespace Tests\Feature\Api;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ProjectTest extends TestCase
{
use RefreshDatabase;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
public function test_unauthenticated_user_cannot_access_projects(): void
{
$response = $this->getJson('/api/projects');
$response->assertStatus(401);
}
public function test_authenticated_user_can_list_projects(): void
{
Sanctum::actingAs($this->user);
Project::factory()->count(3)->create(['user_id' => $this->user->id]);
$response = $this->getJson('/api/projects');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'status', 'priority', 'created_at'],
],
'meta' => ['current_page', 'total'],
]);
}
public function test_user_can_create_project(): void
{
Sanctum::actingAs($this->user);
$response = $this->postJson('/api/projects', [
'title' => 'New SaaS Product',
'description' => 'Building a SaaS application',
'priority' => 'high',
'deadline' => now()->addMonth()->format('Y-m-d'),
]);
$response->assertStatus(201)
->assertJson([
'data' => [
'title' => 'New SaaS Product',
'priority' => 'high',
],
]);
$this->assertDatabaseHas('projects', [
'title' => 'New SaaS Product',
'user_id' => $this->user->id,
]);
}
public function test_user_cannot_access_other_users_project(): void
{
Sanctum::actingAs($this->user);
$otherUser = User::factory()->create();
$project = Project::factory()->create(['user_id' => $otherUser->id]);
$response = $this->getJson("/api/projects/{$project->id}");
$response->assertStatus(403);
}
public function test_validation_errors_return_proper_format(): void
{
Sanctum::actingAs($this->user);
$response = $this->postJson('/api/projects', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['title']);
}
}
Run the tests:
php artisan test --filter=ProjectTest
What Reddit Communities Say About Laravel Sanctum
Discussions across r/laravel, r/PHP, and r/webdev frequently mention:
-
"Sanctum is all you need for 95% of projects." The Laravel community overwhelmingly recommends Sanctum over Passport for most API projects. OAuth2 is only necessary when you are building a platform where third-party applications need to authenticate.
-
"Always use Form Requests, never validate in controllers." Experienced Laravel developers consistently advocate for Form Request classes to keep controllers clean and validation reusable.
-
"API Resources are underrated." Many threads discuss how API Resources prevent leaking sensitive model data and provide a clean transformation layer between your database and API responses.
-
"Token expiration is important but often forgotten." Security-focused discussions emphasize setting token expiration in
config/sanctum.phpand implementing token refresh flows.
Practical Takeaway: Build Your API This Week
Follow this implementation order:
- Day 1: Set up Laravel project, configure Sanctum, create User model with HasApiTokens
- Day 2: Build authentication endpoints (register, login, logout)
- Day 3: Create your main resource (model, migration, controller, request, resource)
- Day 4: Add token abilities, rate limiting, and CORS configuration
- Day 5: Write tests and deploy
Laravel Sanctum API development is one of the most marketable skills for PHP developers. Companies across Nepal and internationally need developers who can build secure, well-structured APIs. Freelance Laravel API projects on platforms like Upwork typically pay NPR 40,000-200,000 depending on complexity.
Frequently Asked Questions
What is the difference between Laravel Sanctum and Passport?
Sanctum provides simple token-based authentication and SPA cookie authentication without OAuth2 complexity. Passport implements full OAuth2 with authorization codes, client credentials, and personal access tokens. Use Sanctum for first-party applications and Passport only when you need third-party OAuth2 integration.
Can Laravel Sanctum be used with mobile applications?
Yes. Mobile apps use Sanctum's token-based authentication. The mobile app sends a login request, receives a token, and includes that token in the Authorization header for all subsequent requests. This works with Flutter, React Native, and native iOS/Android applications.
How do I handle token expiration with Sanctum?
Configure token expiration in config/sanctum.php by setting 'expiration' => 60 * 24 (minutes). Expired tokens are automatically rejected. Implement a token refresh endpoint that issues a new token when the current one is about to expire. For mobile apps, prompt users to re-authenticate when their token expires.
Is Laravel Sanctum secure enough for production?
Yes. Sanctum tokens are stored as SHA-256 hashes in the database, making them secure even if the database is compromised. Combined with HTTPS, rate limiting, input validation, and proper CORS configuration, Sanctum provides production-grade security. Major Laravel applications use Sanctum in production successfully.
How do I version my Laravel API?
Use URL-based versioning with route groups: /api/v1/projects, /api/v2/projects. Create separate controller namespaces for each version. This allows you to maintain backward compatibility while evolving your API. Start with v1 and only create v2 when you need breaking changes.
Learn Laravel API Development at Swift Academy Pokhara
Ready to build production-quality APIs with Laravel and Sanctum? Swift Academy's Laravel course in Pokhara covers API development, authentication, database design, and deployment with real-world projects.
Our hands-on curriculum ensures you build complete API backends that work with mobile apps and SPAs. Join the growing community of Laravel developers in Nepal.
Visit swiftacademy.com.np or visit our Pokhara campus to start building APIs today.
Related Articles
- PHP vs Python for Web Development in 2026
- Building a SaaS Application with Django: Architecture, Auth, and Deployment
- REST API Design Best Practices Every Developer Should Know
Suggested Images
- Hero Image: Code editor showing Laravel Sanctum authentication code with API response in a terminal beside it — Alt text: "Laravel Sanctum API authentication code and response"
- Architecture Diagram: Visual showing API request flow from client through Sanctum middleware to controller and back — Alt text: "Laravel Sanctum API request authentication flow diagram"
- Comparison Table Visual: Formatted comparison of Sanctum vs Passport features as an infographic — Alt text: "Laravel Sanctum vs Passport feature comparison infographic"




