公開日 2026-03-27

Laravelで認証を足す(Breeze 最小導入)

Laravel 13 の既存 Book CRUD に Breeze を追加し、register / login / logout と auth middleware でログイン必須アプリへ進める。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 前記事の CRUD プロジェクトを起点にする
  4. 3. Breeze を追加して認証画面を生成する
  5. 4. 既存の Book 画面を Breeze レイアウトへ移し、ログイン必須にする
  6. コードのポイント
  7. コードのポイント
  8. コードのポイント
  9. コードのポイント
  10. コードのポイント
  11. 5. redirect と register / login / logout を確認する
  12. 6. Breeze で増えたものと、まだ解決していないこと
  13. 7. つまずいたときの確認
  14. Vite manifest not found が出る
  15. 右上のユーザー名メニューを押しても開かない
  16. books 画面が崩れる
  17. 未ログインでも /books が見える
  18. node_modules や public/build の権限で止まる
  19. 8. 次にやるなら認可を足す

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 の接続までを扱います。
誰の本かを分ける Policyuser_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 / component
  • routes/web.phpauth middleware 化
  • 既存 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 loginPOST login が見えれば、認証の入口は追加済みです。

この段階でブラウザから http://localhost:8000/loginhttp://localhost:8000/register を開くと、Breeze が追加した認証画面を確認しやすくなります。
この 2 画面が表示できれば、Breeze の認証 UI は用意できています。

Breeze の login 画面 Breeze の register 画面

4. 既存の Book 画面を Breeze レイアウトへ移し、ログイン必須にする

次は既存 CRUD を Breeze に接続します。
中心になるのは routes/web.phpresources/views/books/*.blade.php です。

まずは routes/web.php を次の内容に置き換えてください。books CRUD をまとめて auth グループへ入れつつ、Breeze のデフォルト遷移先 /dashboardbooks.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::resourcemiddleware('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 へ転送します。
ただし booksauth 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-inputx-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-layoutx-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.phpDashboard リンク部分を次のように差し替えてください。

<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
books route の一覧

次に、ブラウザで http://localhost:8000/books を開いてください。
未ログインなら /login へ移動します。

未ログイン時に表示される login 画面

そのまま register 画面へ進み、ユーザーを 1 件作成します。

ユーザー作成後は、そのままログイン済み状態で /books を開けるはずです。
一覧、作成、編集、削除は、従来どおりの BookController のままで動作確認できます。

ログイン後の Book 一覧画面

ログアウトは右上に直接は出ていません。右上のユーザー名をクリックするとドロップダウンが開くので、その中の 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 registerPOST registerPOST 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 installnpm run build をやり直してください。

右上のユーザー名メニューを押しても開かない

breeze:install blade のあとで frontend assets をビルドし直していないことが多いです。
3章の node:24 コンテナで npm installnpm run build を実行し、ブラウザを再読み込みしてください。

books 画面が崩れる

旧記事の @extends('layouts.app') 前提のままだと、Breeze の {{ $slot }} ベースレイアウトと合いません。
x-app-layout へ移したかどうかを見直してください。

未ログインでも /books が見える

Route::resource('books', ...)Route::middleware('auth')->group(...) の外へ置いていないか確認してください。

node_modulespublic/build の権限で止まる

node:24 コンテナ実行時に -u "$(id -u):$(id -g)" を付けて、生成ファイルの所有者を WSL 側ユーザーへそろえてください。

8. 次にやるなら認可を足す

ここまでで、Laravel アプリは「誰でも触れる CRUD」から「ログイン済みユーザーだけが触れる CRUD」へ進みました。
続きとして読むなら、Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする) です。user_idPolicy を追加し、「自分の本だけ編集できる」状態へ進めます。

前提を見直したいときは Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除) に戻れます。
品質面を先に固めたい場合は、Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions) を挟んでも構いません。

シリーズ 4/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 を始める