Laravel 12 REST API with Sanctum: Complete Tutorial

Laravel 12 REST API with Sanctum

If you have built a Laravel application before, you already know how polished the framework’s conventions are. When it comes to securing a REST API, Laravel Sanctum is the official recommendation and for good reason. It handles token-based authentication without pulling in the full complexity of OAuth2.

This tutorial walks you through building a complete Laravel REST API with Sanctum: user registration, login, logout, and a full set of CRUD endpoints protected by token authentication. By the end, you’ll have a working, testable API and understand why each piece is wired the way it is.

Prerequisites

  • PHP 8.2 or higher
  • Composer
  • A running MySQL database
  • Basic familiarity with Laravel routing, controllers, and Eloquent

Check this: Laravel 8 Controller Tutorial with Example

What Is Laravel Sanctum?

Sanctum is Laravel’s lightweight authentication package for APIs and SPAs. It solves two distinct problems:

  1. API token authentication – issue personal access tokens to users or third party clients, sent in the Authorization header as a Bearer token.
  2. SPA authentication – session-based cookie authentication for SPAs on the same domain.

This tutorial focuses entirely on API token authentication, which is what you need for a mobile app backend, a headless frontend, or any third-party integration.

Sanctum stores tokens in a single personal_access_tokens database table and authenticates requests by checking the Authorization header. Compared to Passport (which implements full OAuth2), Sanctum is simpler to set up and easier to reason about for most API use cases.

Steps to Create Laravel 12 Rest API with Sanctum

Step 1: Create a New Laravel 12 Project

composer create-project laravel/laravel laravel12-api
cd laravel12-api

Open .env and configure your database connection:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_rest_api
DB_USERNAME=root
DB_PASSWORD=your_password

Step 2: Install Sanctum with the API Installer

Laravel 12 ships without routes/api.php by default, but there’s a single Artisan command that creates it and installs Sanctum in one shot:

php artisan install:api

This command does several things:

  • Installs the laravel/sanctum package
  • Publishes the Sanctum configuration to config/sanctum.php
  • Creates routes/api.php and registers it in bootstrap/app.php
  • Creates the personal_access_tokens migration

You don’t need to manually register Sanctum’s service provider or middleware in Laravel 12, the installer handles that for you.

Now run your migrations:

php artisan migrate

Step 3: Add HasApiTokens to the User Model

Open app/Models/User.php and add the HasApiTokens trait:

<?php

namespace App\Models;

use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

HasApiTokens adds three things to the model: the createToken() method for issuing tokens, a tokens() relationship for querying them, and helper methods like tokenCan() for checking token abilities. Without this trait, calls to createToken() will fail.

Note the 'password' => 'hashed' cast — this is the Laravel 10+ way to automatically hash passwords on assignment, replacing the old Hash::make() call in mutators.

Step 4: Build the Authentication Controller

Generate the controller:

php artisan make:controller AuthController

Open app/Http/Controllers/AuthController.php and implement register, login, and logout:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users',
            'password' => 'required|string|min:8',
        ]);

        User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json(['user' => $user, 'token' => $token], 201);

    }

    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required|string',
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['Invalid credentials.'],
            ]);
        }

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json(['user' => $user, 'token' => $token]);
    }

    public function logout(Request $request)
    {
        $request->user()->tokens()->delete();

        return response()->json(['message' => 'Logged out successfully']);
    }

}

A few things worth noting here:

  • createToken()->plainTextToken : Sanctum stores a SHA-256 hash of the token in the database. The plainTextToken property gives you the raw token to return to the client. You can only access the plain-text value at creation time; Sanctum can’t reverse the hash later.
  • ValidationException::withMessages() : This is the correct Laravel pattern for returning a 422 validation error when credentials don’t match, rather than a generic 401. It’s consistent with how Laravel reports validation failures, which makes client-side error handling predictable.

Step 5: Create the Post Model and Migration

php artisan make:model Post -m

Open the generated migration file in database/migrations/ and define the schema:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->text('content');
    $table->timestamps();
});

foreignId('user_id')->constrained() creates the foreign key referencing users.id. The cascadeOnDelete() call means posts are automatically deleted when their owner’s account is removed — a sensible default that avoids orphaned records.

Now update app/Models/Post.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = ['user_id', 'title', 'content'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Run the migration:

php artisan migrate

Step 6: Create the PostController

Generate a resource controller with the model bound:

php artisan make:controller PostController --resource --model=Post

Open app/Http/Controllers/PostController.php and fill in the CRUD methods:

<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        $posts = Post::all();

        return response()->json([
            'success' => true,
            'data' => $posts
        ], 200);
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        $validated = $request->validate([
            'user_id' => 'required',
            'title' => 'required|string|max:255',
            'content' => 'required|string',
        ]);

        $post = Post::create($validated);

        return response()->json([
            'success' => true,
            'message' => 'Post created successfully',
            'data' => $post
        ], 201);
    }

    /**
     * Display the specified resource.
     */
    public function show(Post $post)
    {
        return response()->json([
            'success' => true,
            'data' => $post
        ], 200);
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Post $post)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'content' => 'required|string',
        ]);

        $post->update($validated);

        return response()->json([
            'success' => true,
            'message' => 'Post updated successfully',
            'data' => $post
        ], 200);
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Post $post)
    {
        $post->delete();

        return response()->json([
            'success' => true,
            'message' => 'Post deleted successfully'
        ], 200);
    }
}

The authorizePost() helper ensures users can only read, update, or delete their own posts — a critical ownership check that’s easy to overlook when writing CRUD controllers quickly. The store() method uses $request->user()->posts()->create() rather than Post::create() with a manual user_id, which is cleaner and avoids accidentally creating posts for the wrong user.

For production applications, consider moving this authorization logic into a Laravel Policy for better organisation and testability.

Step 7: Register the API Routes

Open routes/api.php and define all your routes:

<?php

use App\Http\Controllers\AuthController;
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

// Public routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login',    [AuthController::class, 'login']);

// Protected routes — require a valid Sanctum token
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::apiResource('posts', PostController::class);
});

All routes in routes/api.php are automatically prefixed with /api, so the registration endpoint is reachable at /api/register, and posts at /api/posts.

Read Detailed Guide: Explained Laravel Routing

The auth:sanctum middleware reads the Authorization: Bearer <token> header and resolves the authenticated user. If the token is missing or invalid, Laravel returns a 401 response automatically, you don’t need to handle that yourself.

Route::apiResource() registers these five routes for posts:

MethodURIController Method
GET/api/postsindex
POST/api/postsstore
GET/api/posts/{post}show
PUT/PATCH/api/posts/{post}update
DELETE/api/posts/{post}destroy

Step 8: Testing the API

Start the local development server:

php artisan serve

Your API is now running at http://localhost:8000. Before grabbing a testing tool, there’s an important limitation to be aware of.

Why You Cannot Use the Postman Web Version for This

If you open postman.com in your browser and try to send a request to http://localhost:8000, you’ll hit an error along the lines of:

Cloud agent cannot send request to localhost

This happens because the Postman web app routes requests through Postman’s cloud servers — and those servers obviously can’t reach your machine’s localhost. This is a networking constraint, not a bug.

You have two practical options for testing a localhost API:

Option 1: Postman Desktop App (Recommended)

Download and install the Postman desktop app from postman.com/downloads. The desktop app runs natively on your machine and has direct access to localhost with no additional configuration needed.

Once installed:

  1. Create a new request, set the method to POST, and enter http://localhost:8000/api/register
  2. Under the Headers tab, add:
    • Accept: application/json
    • Content-Type: application/json
  3. Under Body, select raw → JSON and paste:
{
    "name": "Test Developer",
    "email": "[email protected]",
    "password": "12345678"
}
  1. Click Send. You should receive a 201 response with the user object and a token.

Copy the token value from the response. For all protected requests, go to the Authorization tab, choose Bearer Token, and paste it there.

Option 2: Thunder Client Extension for VS Code

Thunder Client is another VS Code extension with a graphical interface similar to Postman. Search for “Thunder Client” in the VS Code Extensions panel and install it. A new icon appears in your sidebar.

Thunder Client gives you a GUI for building requests (method, headers, body, auth) without needing to write .http files. It stores collections locally in your project and can be committed to version control. It works with localhost out of the box.

Common Issues and Fixes

401 on every protected request
Make sure you’re sending the Accept: application/json header. Without it, Laravel may redirect to a login page instead of returning JSON. Also verify the token hasn’t been revoked.

“Column not found: user_id” on post creation
Check that user_id is in your Post model’s $fillable array and that the migration ran successfully.

“Route [login] not defined” error
This appears when an unauthenticated request hits a protected route and Laravel can’t find the redirect target. Adding Accept: application/json to your requests prevents this — Sanctum returns 401 instead of attempting a redirect when the client expects JSON.

SQLSTATE[HY000]: No such table: personal_access_tokens
The Sanctum migration hasn’t run. Run php artisan migrate and check there are no migration errors in the output.

Conclusion

Sanctum is the right tool for most Laravel API projects, it’s official, well-documented, and keeps the authentication layer thin. The pattern you’ve built here (validate → create token → protect routes with middleware → scope queries to the authenticated user) scales cleanly whether you’re adding five more resources or splitting into separate microservices later.

The pieces that most commonly cause problems in practice are the Accept: application/json header (without it, auth failures silently redirect instead of returning 401), missing token expiration configuration, and forgetting ownership checks on individual resource routes. All three are covered above, so you’re starting from a solid baseline.

Similar Posts