Build a REST API with Laravel 13 and Sanctum in 30 Minutes

What We're Building
By the end of this tutorial you'll have a working REST API with token-based authentication: users register, log in, and manage their own posts through protected JSON endpoints. Everything here is a production pattern — FormRequest validation, API Resources for response shaping, ownership checks, rate limiting, and a Pest test suite to prove it all works.
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/register | No | Create an account, receive a token |
| POST | /api/login | No | Exchange credentials for a token |
| POST | /api/logout | Yes | Revoke the current token |
| GET | /api/posts | Yes | List your posts (paginated) |
| POST | /api/posts | Yes | Create a post |
| GET | /api/posts/{post} | Yes | Show one post |
| PUT | /api/posts/{post} | Yes | Update your post |
| DELETE | /api/posts/{post} | Yes | Delete your post |
Why Sanctum and Not Passport?
Laravel ships two first-party auth packages, and picking the wrong one costs you days. Passport is a full OAuth2 server — authorization codes, client credentials, scopes. You need it when third-party applications will authenticate against your API. Sanctum is deliberately simpler: it issues personal access tokens that your own mobile app or SPA sends as a Authorization: Bearer header.
If you own both ends of the API — your backend, your frontend — Sanctum is the right default. Less configuration, fewer database tables, no RSA keys to manage. That's what we'll use.
Prerequisites
- PHP 8.4 (via Laravel Herd, Valet, or your package manager)
- Composer 2.x
- A MySQL or PostgreSQL database (SQLite works for following along)
- An HTTP client for testing — curl, Postman, or HTTPie
Step 1: Project Setup (5 minutes)
composer create-project laravel/laravel:^13.0 blog-api
cd blog-api
php artisan install:api
That last command is the important one. install:api does three things: creates routes/api.php, installs Sanctum, and publishes its migration. When it finishes, it reminds you to add the HasApiTokens trait to your User model — do that now:
// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
Configure your database in .env, then migrate. This creates the personal_access_tokens table Sanctum stores tokens in:
php artisan migrate
Step 2: The Post Model (5 minutes)
php artisan make:model Post -m
The migration — note the user_id foreign key and the composite index on the two columns we'll always filter by:
// database/migrations/xxxx_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('content');
$table->boolean('is_published')->default(false);
$table->timestamps();
$table->index(['user_id', 'is_published']);
});
The model:
// app/Models/Post.php
class Post extends Model
{
protected $fillable = ['title', 'content', 'is_published'];
protected function casts(): array
{
return ['is_published' => 'boolean'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Run php artisan migrate again.
Step 3: Authentication Endpoints (8 minutes)
php artisan make:controller Api/AuthController
Three methods: register, login, logout. The login flow is the documented Sanctum pattern for API clients — look the user up, verify the password, issue a token:
// app/Http/Controllers/Api/AuthController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
]);
// The 'hashed' cast on the User model hashes the password automatically
$user = User::create($validated);
return response()->json([
'user' => $user,
'token' => $user->createToken('api')->plainTextToken,
], 201);
}
public function login(Request $request): JsonResponse
{
$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([
'user' => $user,
'token' => $user->createToken('api')->plainTextToken,
]);
}
public function logout(Request $request): JsonResponse
{
// Revoke only the token that made this request
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out.']);
}
}
Two details worth understanding:
plainTextTokenis shown once. Sanctum stores only a SHA-256 hash of it — if the client loses the token, they log in again for a new one.currentAccessToken()->delete()revokes just this device's token. To log a user out everywhere, use$user->tokens()->delete().
Step 4: Validation with FormRequests (4 minutes)
php artisan make:request StorePostRequest
php artisan make:request UpdatePostRequest
// app/Http/Requests/StorePostRequest.php
public function authorize(): bool
{
return true; // route middleware already guarantees an authenticated user
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
'is_published' => ['sometimes', 'boolean'],
];
}
UpdatePostRequest is identical except every rule starts with sometimes so partial updates work. When validation fails on an API request, Laravel automatically returns a 422 with a JSON error bag — no extra code needed.
Step 5: The CRUD Controller (5 minutes)
php artisan make:controller Api/PostController --api
// app/Http/Controllers/Api/PostController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request)
{
$posts = $request->user()
->posts()
->latest()
->paginate(15);
return PostResource::collection($posts);
}
public function store(StorePostRequest $request)
{
$post = $request->user()->posts()->create($request->validated());
return PostResource::make($post)
->response()
->setStatusCode(201);
}
public function show(Request $request, Post $post)
{
abort_if($post->user_id !== $request->user()->id, 403);
return PostResource::make($post);
}
public function update(UpdatePostRequest $request, Post $post)
{
abort_if($post->user_id !== $request->user()->id, 403);
$post->update($request->validated());
return PostResource::make($post);
}
public function destroy(Request $request, Post $post)
{
abort_if($post->user_id !== $request->user()->id, 403);
$post->delete();
return response()->noContent();
}
}
Add the inverse relationship to the User model so $request->user()->posts() works:
// app/Models/User.php
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
The abort_if ownership checks are fine at this size. Once you have more than one protected model, promote them to Policies — same logic, one class per model.
Step 6: API Resources (3 minutes)
Never return raw models from an API — you'll leak columns you add later. A Resource defines the exact contract:
php artisan make:resource PostResource
// app/Http/Resources/PostResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content,
'is_published' => $this->is_published,
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
Collections returned through PostResource::collection() automatically include pagination meta and links blocks.
Step 7: Routes
// routes/api.php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PostController;
use Illuminate\Support\Facades\Route;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:5,1'); // max 5 attempts per minute
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('posts', PostController::class);
});
Everything in routes/api.php is automatically prefixed with /api. (Curious why, and how to structure prefixes as your API grows? I wrote a whole post on it: Why I Prefix Every API Route with Its Module Name in Laravel.)
Step 8: Guaranteed JSON Errors
One gotcha: if a client forgets the Accept: application/json header, Laravel may answer an error with an HTML page. Force JSON for all API routes in bootstrap/app.php:
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->shouldRenderJsonWhen(
fn ($request) => $request->is('api/*')
);
})
Now a missing token returns {"message": "Unauthenticated."} with a 401, and a missing post returns a JSON 404 — consistently, regardless of client headers.
Step 9: Prove It Works — Pest Tests
php artisan make:test PostApiTest --pest
// tests/Feature/PostApiTest.php
use App\Models\Post;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('rejects unauthenticated requests', function () {
$this->getJson('/api/posts')->assertUnauthorized();
});
it('creates a post for the authenticated user', function () {
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/posts', [
'title' => 'My first post',
'content' => 'Written through the API.',
])
->assertCreated()
->assertJsonPath('data.title', 'My first post');
expect(Post::count())->toBe(1);
});
it('blocks access to posts owned by another user', function () {
$post = Post::factory()->create(); // belongs to a different user
Sanctum::actingAs(User::factory()->create());
$this->getJson("/api/posts/{$post->id}")->assertForbidden();
});
php artisan test
Sanctum::actingAs() authenticates the test request without generating a real token — fast and clean.
Try It with curl
# Register and grab the token
curl -s -X POST http://blog-api.test/api/register \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"name":"Dinesh","email":"[email protected]","password":"secret-123"}'
# Create a post with the returned token
curl -s -X POST http://blog-api.test/api/posts \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"title":"Hello API","content":"It works."}'
Fixing: Class "Laravel\Sanctum\Sanctum" not found
This is the error people hit most often when following an older Sanctum tutorial, and the cause is almost never what the message suggests.
Since Laravel 11, Sanctum is no longer installed by default. A fresh application has no laravel/sanctum package, no routes/api.php, and no personal access tokens migration. Any tutorial written for Laravel 10 or earlier silently assumes all three already exist.
The fix is one command:
php artisan install:api
That installs Sanctum, publishes its migration, creates routes/api.php, and registers the API routes. Then migrate and add the trait to your user model:
php artisan migrate
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
If it is already installed and you still get the error
Then the package is present but Laravel has not discovered it — Composer wrote the files while the compiled package manifest still predates them:
composer dump-autoload
php artisan package:discover
php artisan config:clear
Confirm the package genuinely exists before blaming the cache:
composer show laravel/sanctum
If that errors, Sanctum was never installed and install:api is still the answer. If it prints a version and the error survives a cache clear, look for a config file or service provider referencing Laravel\Sanctum\Sanctum that loads before the package registers — a stray entry copied from an old tutorial into config/app.php is the usual culprit.
Where to Go Next
You now have the skeleton every real Laravel API is built on. From here:
- Structure for growth — when you pass a handful of resources, split features into modules: Building a Laravel Modular Monolith with nwidart/laravel-modules.
- Token expiry — Sanctum tokens never expire by default. Set
'expiration' => 60 * 24(minutes) inconfig/sanctum.phpif your threat model needs it. - Ship it — deploy this exact stack to a VPS: How to Set Up a VPS with PHP, Nginx and MySQL.
Questions or a different approach? Drop a comment — I read every one.
Frequently Asked Questions
Should I use Sanctum or Passport for a Laravel API?
How do I protect Laravel API routes with Sanctum?
Do Laravel Sanctum tokens expire?
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.



