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."}'
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.