Laravelで認証を足す(Breeze 最小導入) でログイン必須にした Book CRUD を土台に、今回は所有者単位の認可を加えます。
この 3 本は 最小CRUD -> Breeze認証 -> Policy認可 の順でつながります。この記事は 3 本目で、前提は Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除) と Laravelで認証を足す(Breeze 最小導入) です。
Breeze までは入れたが、ログイン済みユーザーなら誰でも同じ本を編集できる状態で止まりやすい読者を想定しています。読み終えるころには、books.user_id と BookPolicy を追加し、一覧の出し分けと直接アクセス時の 403 を手元で確認できます。
Laravel 13 の Authorization ドキュメントでは、特定 model に対する認可は Policy を使う整理になっています。
今回はその主線に沿って、Book に対する update / delete を BookPolicy へ寄せます。
Policy は、特定 model に対して誰が何をできるかをクラスへ切り出す Laravel の認可機構です。今回の BookPolicy は、そのうち Book の update / delete の判断だけを受け持ちます。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL 連携有効)
composer:2コンテナ- Laravel 13
- SQLite
- Breeze 導入済みの
laravel-minimal-crud-demo
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
1. ゴールと今回の範囲
この記事で着地したいのは次の 4 点です。
- 新しく作成した本に
user_idが保存される - 一覧では、自分の本だけ編集 / 削除ボタンが見える
- 他ユーザーの edit URL を直接開くと 403 になる
BookPolicyを view と controller の両方から使える
今回は「所有者単位の認可」を入れるところまでを扱います。
role / permission まで同時に広げると、Policy の最小形が見えにくくなるためです。
この段階で外す項目は次のとおりです。
- admin / editor のような役割ベース権限
Gate::before(...)や policybefore()による全権限付与#[Authorize]attribute やcanmiddleware の派生パターン- Queue、Notification、メール送信
- テストと CI の追加更新
認証と認可の流れは次の図で追えます。
flowchart LR
L[ログイン済みユーザー] --> I[本一覧]
I -->|自分の本| E[編集 / 削除ボタン表示]
I -->|他人の本| H[ボタン非表示]
H -->|直接 edit URL| F[403]
2. Policy で何を守るかを先に決める
Laravel 13 の Authorization ドキュメントでは、model / resource ごとの認可は gate より policy を使う整理です。
今回の Book は resource 単位の認可なので、判断を BookPolicy へ寄せると責務を追いやすくなります。
先に決めるのは 2 点です。
- 一覧はログイン済みユーザー全員に見せる
- 編集 / 削除だけ所有者に絞る
この形にすると、@can と 403 の違いが見えやすくなります。
一覧でボタンを隠すだけなら画面上はそれらしく見えますが、URL を直接叩けばまだ更新できてしまうかもしれません。そこで controller 側でも同じ policy を通し、直接アクセス時は 403 にします。
今回の役割分担は次のとおりです。
BookPolicy: 誰がBookを更新 / 削除できるかを判断する@can: 一覧画面で不要なボタンを見せないGate::authorize(...): controller で未許可操作を 403 にする
3. books テーブルに user_id を足す
開始位置は前記事のディレクトリです。
cd ~/projects/laravel-minimal-crud-demo
code .
docker compose up -d
docker compose exec app php artisan make:migration add_user_id_to_books_table --table=books
生成された migration の timestamp 部分は各自で異なります。
*_add_user_id_to_books_table.php を次の内容に置き換えてください。
database/migrations/*_add_user_id_to_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::table('books', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->constrained()
->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('books', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
};
続いて app/Models/Book.php を更新します。
所有者を表示するための relation と、作成時に user_id を保存できるようにするためです。
app/Models/Book.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Book extends Model
{
protected $fillable = [
'title',
'author',
'price',
'user_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
最後に migration を流します。
docker compose exec app php artisan migrate
コードのポイント
① 既存データで詰まりにくいように nullable() で足す
Schema::table('books', function (Blueprint $table) {
$table->foreignId('user_id')
->nullable()
->constrained()
->cascadeOnDelete();
});
前記事までで books テーブルに既存データが残っている可能性があるため、ここではいったん nullable() で追加しています。この記事の確認では、以降に作る新しい本を対象に動作を見る形です。
② user() relation が owner 表示の入口になる
class Book extends Model
{
protected $fillable = [
'title',
'author',
'price',
'user_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
一覧で所有者名を出すときは Book::user() をたどります。user_id を持たせただけでは画面側で扱いにくいため、relation をここでそろえておきます。
4. BookPolicy を作る
次は BookPolicy を作成します。
docker compose exec app php artisan make:policy BookPolicy --model=Book
もし model が見つからないと表示された場合は、App\Models\Book を明示して再実行してください。laravel-minimal-crud から続けている構成なら、通常はそのまま Book を解決できます。
生成された app/Policies/BookPolicy.php を次の内容に置き換えてください。
app/Policies/BookPolicy.php:
<?php
namespace App\Policies;
use App\Models\Book;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class BookPolicy
{
public function update(User $user, Book $book): Response
{
return $user->id === $book->user_id
? Response::allow()
: Response::deny('他ユーザーが登録した本は編集できません。');
}
public function delete(User $user, Book $book): Response
{
return $user->id === $book->user_id
? Response::allow()
: Response::deny('他ユーザーが登録した本は削除できません。');
}
}
Laravel 13 では、app/Models/Book.php と app/Policies/BookPolicy.php の命名がそろっていれば policy は自動検出されます。
そのため、この最小例では AppServiceProvider に手動登録を書かなくて構いません。
コードのポイント
① 判断は user_id と id の一致だけに絞る
public function update(User $user, Book $book): Response
{
return $user->id === $book->user_id
? Response::allow()
: Response::deny('他ユーザーが登録した本は編集できません。');
}
ここで必要なのは「自分の本かどうか」の判定だけです。role や team をまだ持ち込まないことで、Policy の最小形が見えやすくなります。
② Response::deny(...) にすると 403 の理由も残せる
return $user->id === $book->user_id
? Response::allow()
: Response::deny('他ユーザーが登録した本は削除できません。');
boolean を返すだけでも認可自体はできます。ただし、Response::deny(...) にすると拒否理由を持たせられ、Gate::authorize(...) で弾いたときの 403 根拠にもなります。
5. Controller で update / delete を止める
view の @can だけでは、直接 URL を開いたときの拒否までは担保できません。
そこで BookController 側でも同じ policy を通します。
app/Http/Controllers/BookController.php:
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\View;
class BookController extends Controller
{
public function index(): View
{
return view('books.index', [
'books' => Book::query()
->with('user')
->orderByDesc('id')
->get(),
]);
}
public function create(): View
{
return view('books.create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'author' => ['required', 'string', 'max:255'],
'price' => ['required', 'integer', 'min:0'],
]);
Book::query()->create([
...$validated,
'user_id' => $request->user()->id,
]);
return redirect()
->route('books.index')
->with('status', '本を追加しました。');
}
public function edit(Book $book): View
{
Gate::authorize('update', $book);
return view('books.edit', [
'book' => $book,
]);
}
public function update(Request $request, Book $book): RedirectResponse
{
Gate::authorize('update', $book);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'author' => ['required', 'string', 'max:255'],
'price' => ['required', 'integer', 'min:0'],
]);
$book->update($validated);
return redirect()
->route('books.index')
->with('status', '本を更新しました。');
}
public function destroy(Book $book): RedirectResponse
{
Gate::authorize('delete', $book);
$book->delete();
return redirect()
->route('books.index')
->with('status', '本を削除しました。');
}
}
コードのポイント
① store で user_id を request から受けずに埋める
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'author' => ['required', 'string', 'max:255'],
'price' => ['required', 'integer', 'min:0'],
]);
Book::query()->create([
...$validated,
'user_id' => $request->user()->id,
]);
フォームから user_id を受け取る必要はありません。所有者はログイン中ユーザーで一意に決まるため、server 側で埋めるほうが安全です。
② Gate::authorize(...) が直接 URL を 403 にする
public function edit(Book $book): View
{
Gate::authorize('update', $book);
return view('books.edit', [
'book' => $book,
]);
}
ここを通しておくと、一覧でボタンを隠していても、他人の本の edit URL を直接開いたときに 403 で止まります。update と destroy も同じ考え方です。
6. 一覧画面で @can を使ってボタン表示を分ける
次は一覧画面です。所有者が誰かを見えるようにしつつ、編集 / 削除ボタンは @can で出し分けます。
resources/views/books/index.blade.php:
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
本一覧
</h2>
<a
href="{{ route('books.create') }}"
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition hover:bg-indigo-500"
>
本を追加する
</a>
</div>
</x-slot>
<div class="py-8">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
@if (session('status'))
<div class="mb-6 rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">
{{ session('status') }}
</div>
@endif
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700">ID</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700">タイトル</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700">著者</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700">価格</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700">所有者</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
@forelse ($books as $book)
<tr>
<td class="px-4 py-3">{{ $book->id }}</td>
<td class="px-4 py-3">{{ $book->title }}</td>
<td class="px-4 py-3">{{ $book->author }}</td>
<td class="px-4 py-3">{{ number_format($book->price) }} 円</td>
<td class="px-4 py-3">
@if ($book->user)
<span class="font-medium text-gray-800">{{ $book->user->name }}</span>
@if ($book->user_id === auth()->id())
<span class="ml-2 rounded-full bg-sky-100 px-2 py-1 text-xs font-semibold text-sky-700">
あなた
</span>
@endif
@else
<span class="text-gray-500">未割り当て</span>
@endif
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
@can('update', $book)
<a
href="{{ route('books.edit', $book) }}"
class="inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 transition hover:bg-gray-50"
>
編集
</a>
@endcan
@can('delete', $book)
<form action="{{ route('books.destroy', $book) }}" method="post">
@csrf
@method('DELETE')
<button
type="submit"
class="inline-flex items-center rounded-md border border-transparent bg-rose-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition hover:bg-rose-500"
>
削除
</button>
</form>
@endcan
@cannot('update', $book)
<span class="text-xs font-medium text-gray-500">
{{ $book->user_id ? '自分の本ではないため編集できません。' : '未割り当ての本のため操作できません。' }}
</span>
@endcannot
</div>
</td>
</tr>
@empty
<tr>
<td class="px-4 py-6 text-sm text-gray-500" colspan="6">
まだ本が登録されていません。
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
コードのポイント
① @can が所有者だけに編集 / 削除ボタンを出す
<div class="flex flex-wrap items-center gap-3">
@can('update', $book)
<a
href="{{ route('books.edit', $book) }}"
class="inline-flex items-center rounded-md border border-gray-300 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 transition hover:bg-gray-50"
>
編集
</a>
@endcan
@can('delete', $book)
<form action="{{ route('books.destroy', $book) }}" method="post">
この @can は BookPolicy の update / delete を参照します。画面上で押せない操作を先に減らせるため、一覧の見通しがよくなります。
② owner 列を出しておくと動作確認がしやすい
<td class="px-4 py-3">
@if ($book->user)
<span class="font-medium text-gray-800">{{ $book->user->name }}</span>
@if ($book->user_id === auth()->id())
<span class="ml-2 rounded-full bg-sky-100 px-2 py-1 text-xs font-semibold text-sky-700">
あなた
</span>
@endif
@else
今回の主題は「誰の本か」を分けることです。所有者列があると、なぜボタンが出たり消えたりするのかを一覧だけで追えます。
7. 2 ユーザーで「自分の本だけ編集できる」を確認する
ここまでできたら、ブラウザで 2 ユーザー確認をします。
前記事の Breeze が入っているため、register / login / logout はそのまま使えます。
ブラウザで http://localhost:8000/books を開き、一覧画面を起点に確認を進めてください。
未ログインなら Breeze の login 画面へ移動するので、そこから 1 人目のユーザーで register / login すると流れを追いやすくなります。
まず route を確認します。
docker compose exec app php artisan route:list --path=books
次の順番で操作すると分かりやすいです。
http://localhost:8000/registerを開き、1 人目のユーザーを登録する- そのままログイン済み状態で
http://localhost:8000/books/createを開き、1 冊登録する - 一覧へ戻って、自分の本に
あなたバッジと編集 / 削除ボタンが出ることを確認する - いったん logout する
- もう一度
http://localhost:8000/registerを開き、2 人目のユーザーを登録する - そのまま
http://localhost:8000/books/createを開き、別の本を 1 冊登録する - 一覧へ戻り、owner 列と操作列を見比べる
1 人目の register では、たとえば次の値を使えます。
- Name:
Alice - Email:
alice@example.com - Password:
password123 - Confirm Password:
password123
1 人目で登録する本の例は次のとおりです。
- タイトル:
Laravelクイックスタート - 著者:
Alice - 価格:
2800
2 人目の register では、たとえば次の値を使えます。
- Name:
Bob - Email:
bob@example.com - Password:
password123 - Confirm Password:
password123
2 人目で登録する本の例は次のとおりです。
- タイトル:
Docker実践入門 - 著者:
Bob - 価格:
3200
期待する見え方は次のとおりです。
- 自分の本:
あなたバッジが出て、編集 / 削除ボタンも見える - 他人の本: owner 名は見えるが、編集 / 削除ボタンは出ない
一覧に 2 人分の本が並ぶと、owner 列と操作列の違いを 1 画面で確認できます。
次に、他人の本の edit URL を直接開いてください。
たとえば、Bob でログインした状態で Alice の本が id=1 なら、http://localhost:8000/books/1/edit を開きます。
このとき Gate::authorize('update', $book) が働き、Laravel は 403 を返します。
これで「一覧ではボタンを隠す」と「直接アクセスを拒否する」の両方がそろいました。
他人の本の edit URL を直接開くと、次のように 403 になります。
8. つまずいたときの確認と次の一歩
既存の本が 未割り当て のまま残る
今回の migration は既存データで詰まりにくいように nullable() で user_id を足しています。前記事までの本がある場合、その行は owner が 未割り当て になり、編集 / 削除できません。この記事の確認では、新しく作った本を対象に見てください。
BookPolicy を作ったのに @can が効かない
app/Policies/BookPolicy.php という場所と、Book / BookPolicy という命名がずれていないか確認してください。Laravel 13 の自動検出は、この命名規則に乗っていることが前提です。
一覧ではボタンが消えたのに、直接 URL では編集できてしまう
その場合は @can だけで止まっています。BookController の edit / update / destroy に Gate::authorize(...) を入れ忘れていないか見直してください。
認可まで入ると、Laravel の基礎主線は実アプリ寄りになります。
次に品質面を固めるなら、Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions) がつながります。
前提を見直したいときは、Laravelで認証を足す(Breeze 最小導入) と Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除) を行き来すると流れを戻しやすいです。
続きは、LaravelでQueueを始める(database queue + worker 最小構成) です。ここまでの CRUD / 認証 / 認可が通っていれば、次は同期リクエストの外へ処理を逃がす流れへ進めます。