Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除) で作った Book CRUD を土台に、今回は Laravel Breeze を追加してログイン必須のアプリへ進めます。
空ディレクトリから Laravel 13 の現行 auth 導線を確認したい場合は、Laravel 13のStarter Kitsで認証付きアプリを始める を先に見るほうがつながりやすいです。ここで扱うのは fresh app の説明ではなく、既存の CRUD へ認証を後付けする差分です。
この 3 本は 最小CRUD -> Breeze認証 -> Policy認可 の順でつながります。この記事はその 2 本目で、前提は Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除)、次は Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) です。
Laravelで最小CRUDを作る までは進めたが、その先で認証の入れどころに迷う読者を想定しています。読み終えるころには、既存の Book CRUD に Laravel Breeze を追加し、register / login / logout と auth middleware を通した構成が手元で動くところまで進めます。
Laravel 13 の公式ドキュメントでは、認証の入口が starter kit 全体として整理され、React / Vue / Svelte / Livewire が前面に出ます。
このシリーズでは既存の Blade CRUD をそのまま伸ばしたいため、Laravel 13 互換リリースが出ている Breeze を「既存プロジェクトへ最小差分で認証を足す手段」として扱う構成にします。
Breeze は、login / register / logout などの最小認証画面と route を追加できる軽量な starter kit です。この記事では、fresh app の説明ではなく、既存の Blade CRUD へ後付けする手順だけを追います。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL 連携有効)
composer:2コンテナnode:24コンテナ
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
1. ゴールと非対象
この記事で着地したいのは次の 4 点です。
- 既存 CRUD プロジェクトへ
Laravel Breezeを組み込む - 未ログインで
/booksを開いたときに/loginへ送られる流れを確認する - register / login / logout をブラウザで一通り試す
- ログイン後の
Book一覧 / 作成 / 編集 / 削除を従来どおり使える状態にする
今回は Breeze 導入と auth middleware の接続までを扱います。
誰の本かを分ける Policy や user_id まで同時に入れると、Breeze の最小導入と auth middleware の役割が見えにくくなるためです。
この段階で外す項目は次の 5 つです。
Policy/Gateによる認可- メール認証、2FA、ソーシャルログイン
- React / Vue / Svelte / Livewire 版の starter kit
- パスワードリセットやプロフィール画面の本格カスタマイズ
- テストや CI の追加更新
redirect の流れは次の図です。
flowchart LR
G[未ログイン] -->|GET /books| L["/login"]
L -->|register or login| A[ログイン済み]
A -->|GET /books| B[Book CRUD]
A -->|POST /logout| L
2. 前記事の CRUD プロジェクトを起点にする
以降は、前記事で作った laravel-minimal-crud-demo をそのまま使います。
laravel-quality-gate まで進めている場合も、同じディレクトリのままで構いません。
既存プロジェクトを起動します。
cd ~/projects/laravel-minimal-crud-demo
code .
docker compose up -d
いま動いているのは、まだ誰でも触れる Book CRUD。
今回触るのは次の 3 点です。
Breezeによる認証用 route / view / componentroutes/web.phpのauthmiddleware 化- 既存
books画面の Breeze レイアウト対応
BookController や migration を全部書き直す話ではありません。
既存の CRUD を残したまま、ログイン必須アプリへ一段進める作業です。
3. Breeze を追加して認証画面を生成する
Laravel 13 の公式導線は laravel new 時の starter kit 選択ですが、既存プロジェクトへあとから Blade 認証を足すなら Breeze の差分導入が分かりやすいです。最初のコマンドは次の 2 本です。
docker compose exec app composer require --dev laravel/breeze
docker compose exec app php artisan breeze:install blade
この 2 コマンドで、/login、/register、/logout などの認証 route と、Blade コンポーネント、Tailwind / Vite 前提の view が一式そろいます。
注意点は 1 つだけ。
既存 CRUD 記事では自前の resources/views/layouts/app.blade.php を使っていましたが、Breeze も同名のレイアウトを持ち込みます。Breeze 側は {{ $slot }} を使うコンポーネントレイアウトなので、前記事の @extends('layouts.app') ベースの books 画面はそのままでは噛み合いません。
続いて frontend assets をビルドします。composer:2 コンテナには npm が入っていないため、ここだけ node:24 コンテナを使います。
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app node:24 npm install
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app node:24 npm run build
Vite manifest not found で止まるときは、この npm run build がまだ終わっていません。Breeze の追加後にビルドし直しておくと、右上のユーザー名メニューも動きます。
最後に、認証系 route が増えたことを確認します。
docker compose exec app php artisan route:list --path=login
GET|HEAD login と POST login が見えれば、認証の入口は追加済みです。
この段階でブラウザから http://localhost:8000/login と http://localhost:8000/register を開くと、Breeze が追加した認証画面を確認しやすくなります。
この 2 画面が表示できれば、Breeze の認証 UI は用意できています。
4. 既存の Book 画面を Breeze レイアウトへ移し、ログイン必須にする
次は既存 CRUD を Breeze に接続します。
中心になるのは routes/web.php と resources/views/books/*.blade.php です。
まずは routes/web.php を次の内容に置き換えてください。books CRUD をまとめて auth グループへ入れつつ、Breeze のデフォルト遷移先 /dashboard を books.index へ転送します。
routes/web.php:
<?php
use App\Http\Controllers\BookController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/books');
Route::get('/dashboard', function () {
return redirect()->route('books.index');
})->middleware('auth')->name('dashboard');
Route::middleware('auth')->group(function () {
Route::resource('books', BookController::class)->except(['show']);
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
require __DIR__.'/auth.php';
コードのポイント
① auth グループで CRUD 全体にログイン必須を課す
Route::redirect('/', '/books');
Route::get('/dashboard', function () {
return redirect()->route('books.index');
})->middleware('auth')->name('dashboard');
Route::middleware('auth')->group(function () {
Route::resource('books', BookController::class)->except(['show']);
...
});
Route::resource を middleware('auth')->group の中に入れるだけで、一覧・作成・編集・削除のすべてにログイン必須が掛かります。コントローラー側を変更せず、ルート定義だけで認証境界を引けます。
② /dashboard を転送して Breeze のログイン後遷移を吸収する
Route::get('/dashboard', function () {
return redirect()->route('books.index');
})->middleware('auth')->name('dashboard');
Breeze のログイン後は /dashboard へ飛ぶ実装になっています。そのまま残すと books とは別のページが存在してしまうため、ここで books.index へ転送します。name('dashboard') を残しておくことで Breeze 内部の参照も壊れません。
これで root の向き先は /books です。
Breeze のログイン後遷移は /dashboard のため、この route では books.index へ転送します。
ただし books は auth group の中にあるため、未ログインなら /login へ移動し、ログイン後は同じ /books が一覧画面として動きます。
次に books 画面を x-app-layout へ移します。
先に _form.blade.php を置き換えます。タイトル・著者・価格の入力欄を Breeze の UI コンポーネントで統一し、バリデーションエラー表示まで含む共通フォーム部品です。
resources/views/books/_form.blade.php:
@php
$book ??= null;
@endphp
<div>
<x-input-label for="title" :value="__('タイトル')" />
<x-text-input
id="title"
class="mt-1 block w-full"
type="text"
name="title"
:value="old('title', $book?->title ?? '')"
required
autofocus
/>
<x-input-error :messages="$errors->get('title')" class="mt-2" />
</div>
<div>
<x-input-label for="author" :value="__('著者')" />
<x-text-input
id="author"
class="mt-1 block w-full"
type="text"
name="author"
:value="old('author', $book?->author ?? '')"
required
/>
<x-input-error :messages="$errors->get('author')" class="mt-2" />
</div>
<div>
<x-input-label for="price" :value="__('価格(円)')" />
<x-text-input
id="price"
class="mt-1 block w-full"
type="number"
min="0"
name="price"
:value="old('price', $book?->price ?? '')"
required
/>
<x-input-error :messages="$errors->get('price')" class="mt-2" />
</div>
コードのポイント
① x-text-input と x-input-error で入力欄とエラーをセットにする
<div>
<x-input-label for="title" :value="__('タイトル')" />
<x-text-input
id="title"
class="mt-1 block w-full"
type="text"
name="title"
:value="old('title', $book?->title ?? '')"
required
autofocus
/>
<x-input-error :messages="$errors->get('title')" class="mt-2" />
</div>
x-text-input は Breeze が生成するコンポーネントで、Tailwind スタイルが適用済みです。:value="old('title', $book?->title ?? '')" により、バリデーション失敗後も入力値が保持され、編集画面では既存値が初期表示されます。
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>
</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">
<div class="flex flex-wrap gap-3">
<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>
<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>
</div>
</td>
</tr>
@empty
<tr>
<td class="px-4 py-6 text-sm text-gray-500" colspan="5">
まだ本が登録されていません。
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
コードのポイント
① x-app-layout と x-slot で Breeze のページ構造を作る
<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>
x-app-layout で Breeze のナビゲーション付きレイアウトが適用されます。x-slot name="header" に渡した内容がページ上部のヘッダー領域に挿入されます。旧記事の @extends('layouts.app') から x-app-layout へ移した理由はここにあります。
② 削除フォームで @csrf と @method('DELETE') を組み合わせる
<form action="{{ route('books.destroy', $book) }}" method="post">
@csrf
@method('DELETE')
<button
HTML フォームは GET / POST しか送れないため、@method('DELETE') で Laravel に DELETE として扱わせます。@csrf はクロスサイトリクエスト偽造対策のトークンで、Laravel の POST 系フォームでは必須です。
resources/views/books/create.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.index') }}"
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>
</div>
</x-slot>
<div class="py-8">
<div class="mx-auto max-w-3xl sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<form action="{{ route('books.store') }}" method="post" class="space-y-6">
@csrf
@include('books._form')
<x-primary-button>
保存する
</x-primary-button>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
コードのポイント
① @include で共通フォームを再利用する
<form action="{{ route('books.store') }}" method="post" class="space-y-6">
@csrf
@include('books._form')
<x-primary-button>
_form.blade.php を @include することで、作成・編集で同じ入力欄を使い回せます。Book モデルを渡さない場合は $book ??= null でフォールバックするので、create ではそのまま空フォームが出ます。
resources/views/books/edit.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.index') }}"
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>
</div>
</x-slot>
<div class="py-8">
<div class="mx-auto max-w-3xl sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<form action="{{ route('books.update', $book) }}" method="post" class="space-y-6">
@csrf
@method('PATCH')
@include('books._form', ['book' => $book])
<x-primary-button>
更新する
</x-primary-button>
</form>
</div>
</div>
</div>
</div>
</x-app-layout>
コードのポイント
① @include に ['book' => $book] を渡して初期値を注入する
<form action="{{ route('books.update', $book) }}" method="post" class="space-y-6">
@csrf
@method('PATCH')
@include('books._form', ['book' => $book])
@include の第 2 引数でデータを渡すと、_form.blade.php 内の $book に既存の Book インスタンスが入ります。これにより :value="old('title', $book?->title ?? '')" が既存値を初期表示し、編集前の内容がフォームに反映されます。
最後に、Breeze が作る navigation へ Books リンクを足します。
resources/views/layouts/navigation.blade.php の Dashboard リンク部分を次のように差し替えてください。
<x-nav-link :href="route('books.index')" :active="request()->routeIs('books.*')">
{{ __('Books') }}
</x-nav-link>
レスポンシブメニュー側も同じです。
<x-responsive-nav-link :href="route('books.index')" :active="request()->routeIs('books.*')">
{{ __('Books') }}
</x-responsive-nav-link>
古い @extends('layouts.app') を引きずるより、books 画面も Breeze の部品へ寄せたほうが構成が揃います。layouts.app の役割が Breeze 側で変わったためです。
5. redirect と register / login / logout を確認する
最初に books route が auth group に入っているか確認します。
docker compose exec app php artisan route:list --path=books
次に、ブラウザで http://localhost:8000/books を開いてください。
未ログインなら /login へ移動します。
そのまま register 画面へ進み、ユーザーを 1 件作成します。
ユーザー作成後は、そのままログイン済み状態で /books を開けるはずです。
一覧、作成、編集、削除は、従来どおりの BookController のままで動作確認できます。
ログアウトは右上に直接は出ていません。右上のユーザー名をクリックするとドロップダウンが開くので、その中の Log Out でセッションを閉じます。ログアウト後に再度 /books へアクセスすると、一覧はもう開けません。
認証 route も見ておくと整理しやすいです。
docker compose exec app php artisan route:list --path=register
docker compose exec app php artisan route:list --path=logout
GET register、POST register、POST logout が見えれば、最小の認証導線はそろっています。
6. Breeze で増えたものと、まだ解決していないこと
今回増える要素は主に次の 4 つです。
- login / register / logout などの認証 route
- 認証用 controller と request 処理
- guest 用 / auth 用の Blade レイアウトと component
- Tailwind / Vite 前提の frontend assets
ここまでで作れたのは「未ログインでは books に入れない」という段階までです。
まだ全ログインユーザーが同じ books を見られますし、誰が作った本かも保持していません。
つまり、認証は入ったが認可はまだ入っていません。
次に必要になるのは books.user_id を持たせ、Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) の内容で「自分の本だけ編集 / 削除できる」状態へ進めることです。
7. つまずいたときの確認
Vite manifest not found が出る
ほぼ npm run build が終わっていないケースです。
node:24 コンテナで npm install と npm run build をやり直してください。
右上のユーザー名メニューを押しても開かない
breeze:install blade のあとで frontend assets をビルドし直していないことが多いです。
3章の node:24 コンテナで npm install と npm run build を実行し、ブラウザを再読み込みしてください。
books 画面が崩れる
旧記事の @extends('layouts.app') 前提のままだと、Breeze の {{ $slot }} ベースレイアウトと合いません。
x-app-layout へ移したかどうかを見直してください。
未ログインでも /books が見える
Route::resource('books', ...) を Route::middleware('auth')->group(...) の外へ置いていないか確認してください。
node_modules や public/build の権限で止まる
node:24 コンテナ実行時に -u "$(id -u):$(id -g)" を付けて、生成ファイルの所有者を WSL 側ユーザーへそろえてください。
8. 次にやるなら認可を足す
ここまでで、Laravel アプリは「誰でも触れる CRUD」から「ログイン済みユーザーだけが触れる CRUD」へ進みました。
続きとして読むなら、Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) です。user_id と Policy を追加し、「自分の本だけ編集できる」状態へ進めます。
前提を見直したいときは Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除) に戻れます。
品質面を先に固めたい場合は、Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions) を挟んでも構いません。