公開日 2026-04-05

LaravelでSanctum認証APIを作る

Laravel 13 の fresh app に Sanctum を導入し、login で token を発行して認証付き Book API を作る手順をまとめる。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 先に全体像をつかむ
  4. 3. fresh app を作成して起動する
  5. 4. install:api で Sanctum を入れる
  6. 5. login と Book API を作る
  7. 6. seed と確認コマンドを通す
  8. 7. コードのポイント
  9. install:api だけでは HasApiTokens は入らない
  10. login では password 検証と token の張り直しを分ける
  11. auth:sanctum を route group に掛けると保護範囲が読みやすい
  12. 8. つまずきやすい点
  13. install:api --without-tests は使えない
  14. HasApiTokens を足し忘れると login で止まる
  15. token を付けないと Unauthenticated. が返る
  16. 8000 番ポートが埋まっている場合は APP_URL もそろえる
  17. 9. まとめ

Laravel の画面認証は触ったが、次に Bearer token 付き API をどこから作ればよいかで止まりやすい読者向けの記事です。

ここでは fresh app から Sanctum を導入し、login で発行した token を使って認証付き API route を呼ぶところまで進めます。既存の CRUD や Starter Kit の成果物は前提にしません。React / Vue と組み合わせる SPA 向け cookie 認証は本記事の対象外です。

Sanctum は、Laravel で SPA 向けの cookie 認証と API token 認証を扱える認証パッケージです。受信時は、まず session cookie を見て、なければ Authorization: Bearer ... の token を見ます。本記事で扱うのは、personal access token を使う最小の API 認証だけです。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)
  • composer:2 コンテナ
  • Laravel 13
  • Sanctum 4
  • SQLite

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。

1. ゴールと非対象

この記事で到達する状態は次のとおりです。

  • Laravel 13 の fresh app へ Sanctum を導入できる
  • POST /api/login で token を発行できる
  • GET /api/booksauth:sanctum を設定する
  • POST /api/logout で現在の token を無効化できる
  • route:listtinkercurl で自分で確認できる

今回は personal access token を使う最小 API に絞ります。次の内容は扱いません。

  • Breeze / Starter Kits の画面認証詳細
  • React / Vue / Inertia / Livewire と組み合わせる SPA 構成
  • ability / scope を使った細かい権限制御
  • テスト、CI、デプロイ

2. 先に全体像をつかむ

開始位置のディレクトリは ~/projects/laravel-sanctum-auth-api-demo です。そのディレクトリで fresh app を作り、install:apiSanctum を入れたあと、login endpoint と保護 API route を足します。

flowchart LR
	A[fresh app 作成] --> B[compose.yml と .env を整える]
	B --> C[php artisan install:api]
	C --> D[User model に HasApiTokens を追加]
	D --> E[login と books route を実装]
	E --> F[migrate:fresh --seed]
	F --> G[login で token 発行]
	G --> H[Bearer token 付きで /api/books を呼ぶ]

Browser 向けの認証入口を整理したい場合は、Laravel 13のStarter Kitsで認証付きアプリを始める を先に読むとつながりが見えやすくなります。今回はそこから一歩進めて、「API 側で token をどう出すか」が本題です。

3. fresh app を作成して起動する

開始位置は次のディレクトリです。

mkdir -p ~/projects/laravel-sanctum-auth-api-demo
cd ~/projects/laravel-sanctum-auth-api-demo
code .

Laravel プロジェクトは composer:2 コンテナから作成します。-u "$(id -u):$(id -g)" を付けるのは、生成ファイルを WSL 側の自分の所有にそろえるためです。ここを外すと、VS Code から保存するときに権限エラーが出やすくなります。

docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .

compose.yml を作成します。

services:
  app:
    image: composer:2
    user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
    working_dir: /app
    environment:
      HOME: /tmp
      XDG_CONFIG_HOME: /tmp/.config
    volumes:
      - ./:/app
    ports:
      - "8000:8000"
    command: php artisan serve --host=0.0.0.0 --port=8000

.env の該当部分は次のとおりです。

APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
DB_DATABASE=/app/database/database.sqlite

Laravel 13 の fresh app では database/database.sqlite が自動作成されます。ここでは APP_URLDB_DATABASE を確認すれば十分です。

同じシェルで次を実行し、docker compose 側でも自分の UID / GID を使うようにします。

export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d

起動したら、Welcome 画面を確認します。

curl -I http://localhost:8000
  • 期待結果: HTTP/1.1 200 OK

ブラウザで http://localhost:8000 を開き、Laravel の welcome 画面が見えれば準備完了です。

4. install:api で Sanctum を入れる

ここから API 用の土台を追加します。現行 Laravel 13 では composer require laravel/sanctum を手で並べるより、install:api を使うほうが整理しやすくなります。

docker compose exec app php artisan install:api

途中で次のような確認が出たら、そのまま yes で進めてください。

One new database migration has been published. Would you like to run all pending database migrations? (yes/no) [yes]:

このコマンドで起きる主な変更は 4 点です。

  • composer.jsonlaravel/sanctum が追加される
  • routes/api.php が生成される
  • bootstrap/app.php の API ルート読み込みが有効になる
  • create_personal_access_tokens_table migration が追加される

注意したいのは 1 点だけです。install:api の最後に Please add the [Laravel\Sanctum\HasApiTokens] trait to your User model. と表示されるとおり、User model への trait 追加は自動ではありません。

app/Models/User.php を次の内容に更新します。

<?php

namespace App\Models;

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

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
	/** @use HasFactory<UserFactory> */
	use HasApiTokens, HasFactory, Notifiable;

	protected function casts(): array
	{
		return [
			'email_verified_at' => 'datetime',
			'password' => 'hashed',
		];
	}
}

ここまでで routes/api.php の読み込みと personal access token の土台が整った状態です。

5. login と Book API を作る

次に一覧用の Book と、token を出す controller を作ります。

docker compose exec app php artisan make:model Book -m
docker compose exec app php artisan make:seeder BookSeeder
docker compose exec app php artisan make:controller Api/AuthController
docker compose exec app php artisan make:controller Api/BookController

生成された migration の timestamp 部分は各自で異なります。database/migrations/*_create_books_table.php を次の内容に置き換えてください。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
	public function up(): void
	{
		Schema::create('books', function (Blueprint $table) {
			$table->id();
			$table->string('title');
			$table->string('author');
			$table->unsignedInteger('price');
			$table->timestamps();
		});
	}

	public function down(): void
	{
		Schema::dropIfExists('books');
	}
};

app/Models/Book.php は次の内容へ更新します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
	protected $fillable = [
		'title',
		'author',
		'price',
	];
}

次に database/seeders/BookSeeder.php を作成してください。

<?php

namespace Database\Seeders;

use App\Models\Book;
use Illuminate\Database\Seeder;

class BookSeeder extends Seeder
{
	public function run(): void
	{
		Book::query()->insert([
			['title' => 'Laravel実践入門', 'author' => '山田太郎', 'price' => 3200, 'created_at' => now(), 'updated_at' => now()],
			['title' => 'Docker開発環境ガイド', 'author' => '佐藤花子', 'price' => 2800, 'created_at' => now(), 'updated_at' => now()],
			['title' => 'API設計の基礎', 'author' => '鈴木一郎', 'price' => 3500, 'created_at' => now(), 'updated_at' => now()],
			['title' => 'テスト駆動開発メモ', 'author' => '高橋次郎', 'price' => 2600, 'created_at' => now(), 'updated_at' => now()],
			['title' => 'SQL最適化ノート', 'author' => '伊藤美咲', 'price' => 3000, 'created_at' => now(), 'updated_at' => now()],
			['title' => 'PHP 8.3ハンドブック', 'author' => '中村健', 'price' => 3400, 'created_at' => now(), 'updated_at' => now()],
		]);
	}
}

database/seeders/DatabaseSeeder.php は次の内容へ更新します。デモ用ユーザーを 1 人固定で入れ、あわせて BookSeeder を呼ぶ形です。

<?php

namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;

class DatabaseSeeder extends Seeder
{
	public function run(): void
	{
		User::query()->updateOrCreate(
			['email' => 'api-demo@example.com'],
			[
				'name' => 'API Demo User',
				'password' => Hash::make('Password123!'),
			],
		);

		$this->call(BookSeeder::class);
	}
}

app/Http/Controllers/Api/AuthController.php は次の内容で作成します。login では email / password を検証し、成功時に 1 本だけ token を残す仕組みです。

<?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;

class AuthController extends Controller
{
	public function login(Request $request): JsonResponse
	{
		$validated = $request->validate([
			'email' => ['required', 'email'],
			'password' => ['required', 'string'],
		]);

		$user = User::query()->where('email', $validated['email'])->first();

		if (! $user || ! Hash::check($validated['password'], $user->password)) {
			return response()->json([
				'message' => 'メールアドレスまたはパスワードが違います。',
			], 422);
		}

		$user->tokens()->delete();

		return response()->json([
			'token' => $user->createToken('book-api-token')->plainTextToken,
			'user' => [
				'id' => $user->id,
				'name' => $user->name,
				'email' => $user->email,
			],
		]);
	}

	public function logout(Request $request): JsonResponse
	{
		$request->user()->currentAccessToken()?->delete();

		return response()->json([
			'message' => 'ログアウトしました。',
		]);
	}
}

app/Http/Controllers/Api/BookController.php は次の内容にします。

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Book;
use Illuminate\Http\JsonResponse;

class BookController extends Controller
{
	public function index(): JsonResponse
	{
		return response()->json(
			Book::query()
				->orderByDesc('id')
				->paginate(5)
		);
	}
}

最後に routes/api.php を次の内容に更新します。

<?php

use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\BookController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

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

Route::middleware('auth:sanctum')->group(function () {
	Route::get('/user', function (Request $request) {
		return $request->user();
	});

	Route::post('/logout', [AuthController::class, 'logout']);
	Route::get('/books', [BookController::class, 'index']);
});

6. seed と確認コマンドを通す

ここまで揃ったら、テーブルを作り直してデータを投入してください。

docker compose exec app php artisan migrate:fresh --seed

API route を確認します。

docker compose exec app php artisan route:list --path=api

期待する route は次の 4 本です。

  • POST api/login
  • POST api/logout
  • GET api/books
  • GET api/user
route:list --path=api の結果。api/login api/logout api/books api/user が並んでいる状態

Seeder の件数確認は tinker で行います。tinker は Laravel の REPL で、Eloquent や PHP コードをその場で試せます。docker compose exec に付けている -T は TTY 割り当てを無効化し、--execute の non-interactive 実行を安定させるためです。

docker compose exec -T app php artisan tinker --execute="echo App\\Models\\User::count().PHP_EOL; echo App\\Models\\Book::count().PHP_EOL;"

期待結果は次の 2 行です。

1
6

次は login して token を受け取ります。

curl -s -X POST http://localhost:8000/api/login \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"email":"api-demo@example.com","password":"Password123!"}'

レスポンスは次のようになります。token の値は実行ごとに変わるので、文字列そのものではなく JSON の形を確認してください。

{"token":"1|...","user":{"id":1,"name":"API Demo User","email":"api-demo@example.com"}}

そのまま token をシェル変数へ入れ、Book API を呼びます。

TOKEN=$(curl -s -X POST http://localhost:8000/api/login \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"email":"api-demo@example.com","password":"Password123!"}' \
  | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')

curl -s http://localhost:8000/api/books \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer $TOKEN"

current_pagedatalast_page などを含む JSON が返れば成功です。今回の seed は 6 件なので、last_page2 になります。

最後に logout して current token を削除してください。

curl -s -X POST http://localhost:8000/api/logout \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer $TOKEN"

成功すると message キーを含む JSON が返ります。日本語メッセージ部分は端末の文字コード設定によって文字化けすることがあるため、ここではキー構造を確認してください。

{"message":"..."}

7. コードのポイント

install:api だけでは HasApiTokens は入らない

install:apiSanctum の package、routes/api.php、personal access token 用 migration をまとめて追加します。ただし User model へ HasApiTokens を足す作業は自動ではありません。createToken() を使うにはこの trait が必須です。

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
	use HasApiTokens, HasFactory, Notifiable;
}

login では password 検証と token の張り直しを分ける

最小構成なら Hash::check() で email / password を照合し、成功時に新しい token を返す形で十分です。今回は読者が token の増え方で迷わないよう、発行前に既存 token を削除しています。

if (! $user || ! Hash::check($validated['password'], $user->password)) {
	return response()->json([
		'message' => 'メールアドレスまたはパスワードが違います。',
	], 422);
}

$user->tokens()->delete();

return response()->json([
	'token' => $user->createToken('book-api-token')->plainTextToken,
]);

auth:sanctum を route group に掛けると保護範囲が読みやすい

login だけを公開し、それ以外を auth:sanctum group へ入れると API の境界が分かりやすくなります。今回の規模なら middleware を route ごとに散らすより追いやすい構成です。

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

Route::middleware('auth:sanctum')->group(function () {
	Route::post('/logout', [AuthController::class, 'logout']);
	Route::get('/books', [BookController::class, 'index']);
});

8. つまずきやすい点

install:api --without-tests は使えない

現行 Laravel 13 では install:api --without-testsThe "--without-tests" option does not exist. で失敗しました。install:api をそのまま実行してください。

HasApiTokens を足し忘れると login で止まる

createToken()HasApiTokens trait が前提です。install:api 後のメッセージどおり、User model を自分で更新する必要があります。

token を付けないと Unauthenticated. が返る

curl -s http://localhost:8000/api/books -H 'Accept: application/json' のように Bearer token を省くと、レスポンスは {"message":"Unauthenticated."} です。Authorization: Bearer $TOKEN を忘れていないか確認してください。

8000 番ポートが埋まっている場合は APP_URL もそろえる

docker compose up -dBind for 0.0.0.0:8000 failed: port is already allocated が出たら、compose.yml の左側だけを 8001:8000 に変えます。.envAPP_URLhttp://localhost:8001 に合わせてください。

9. まとめ

fresh app から Sanctum を入れ、login で token を発行し、Book API を auth:sanctum で保護するところまで 1 本で確認できました。画面認証の入口を整理したい場合は、Laravel 13のStarter Kitsで認証付きアプリを始める と合わせて読むと、browser 側と API 側の役割分担が見えやすくなります。

シリーズ 13/16

このシリーズ

Laravelの基本を最初から通す

  1. 1. Laravel入門(Route / Controller / View / Model 最小構成)
  2. 2. Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除)
  3. 3. Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions)
  4. 4. Laravelで認証を足す(Breeze 最小導入)
  5. 5. Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする)
  6. 6. LaravelでQueueを始める(database queue + worker 最小構成)
  7. 7. Laravelでスケジューラを動かす(Command + Scheduler 最小構成)
  8. 8. Laravelでファイルアップロードを扱う(Storage + validation)
  9. 9. Laravelでリレーションを扱う(User / Book / Category の基本)
  10. 10. Laravelで検索・並び替え・ページネーション付き一覧を作る
  11. 11. LaravelでLivewireを始める
  12. 12. LaravelでLivewire一覧画面を作る(検索・並び替え・ページネーション)
  13. 13. LaravelでSanctum認証APIを作る 現在の記事
  14. 14. LaravelでBlade / Livewire / Inertia をどう使い分けるか
  15. 15. LaravelでInertia + Vue.js を始める
  16. 16. LaravelでInertia + React を始める