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 10 min readIntermediate 10,049 views
ShareX / TwitterLinkedIn
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.

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

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:

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