Skip to content
TutorialLaravel

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

D

Dinesh Wijethunga

April 22, 2026Reviewed Jul 10, 2026 9 min readIntermediate 9,955 views
ShareX / TwitterLinkedIn
🎓

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.

MethodEndpointAuthPurpose
POST/api/registerNoCreate an account, receive a token
POST/api/loginNoExchange credentials for a token
POST/api/logoutYesRevoke the current token
GET/api/postsYesList your posts (paginated)
POST/api/postsYesCreate a post
GET/api/posts/{post}YesShow one post
PUT/api/posts/{post}YesUpdate your post
DELETE/api/posts/{post}YesDelete 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:

  • plainTextToken is 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:

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?
Use Sanctum when you own both ends of the API (your backend plus your own SPA or mobile app) — it issues simple bearer tokens with minimal setup. Use Passport only when third-party applications need to authenticate against your API via full OAuth2 flows.
How do I protect Laravel API routes with Sanctum?
Wrap them in Route::middleware('auth:sanctum') and have clients send the token in an Authorization: Bearer header. Requests without a valid token receive a 401 JSON response.
Do Laravel Sanctum tokens expire?
Not by default — they live until revoked. Set the 'expiration' value (in minutes) in config/sanctum.php to enforce expiry, or revoke tokens with currentAccessToken()->delete() on logout.
D
Dinesh Wijethunga

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.

Comments(3)

Guest comments are held for moderation.

You might also like