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/booksにauth:sanctumを設定するPOST /api/logoutで現在の token を無効化できるroute:list、tinker、curlで自分で確認できる
今回は 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:api で Sanctum を入れたあと、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_URL と DB_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.jsonにlaravel/sanctumが追加されるroutes/api.phpが生成されるbootstrap/app.phpの API ルート読み込みが有効になるcreate_personal_access_tokens_tablemigration が追加される
注意したいのは 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/loginPOST api/logoutGET api/booksGET 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_page、data、last_page などを含む JSON が返れば成功です。今回の seed は 6 件なので、last_page は 2 になります。
最後に 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:api は Sanctum の 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-tests は The "--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 -d で Bind for 0.0.0.0:8000 failed: port is already allocated が出たら、compose.yml の左側だけを 8001:8000 に変えます。.env の APP_URL も http://localhost:8001 に合わせてください。
9. まとめ
fresh app から Sanctum を入れ、login で token を発行し、Book API を auth:sanctum で保護するところまで 1 本で確認できました。画面認証の入口を整理したい場合は、Laravel 13のStarter Kitsで認証付きアプリを始める と合わせて読むと、browser 側と API 側の役割分担が見えやすくなります。