【Laravel】Laravel Breezeを使ってログイン認証機能を実装する|簡単な掲示板アプリの作成

Laravelの認証パッケージ「Breeze」を使い、掲示板アプリにログイン機能を実装する手順を解説します。ユーザー登録やログイン機能はもちろん、投稿した本人だけが記事を編集・削除できるようにするアクセス制御の方法まで、初心者にもわかりやすく説明します。

作成日: 更新日:

開発環境

  • OS: Windows10
  • Visual Studio Code: 1.73.0
  • PHP: 8.3.11
  • Laravel: 11.29.0
  • laravel/breeze: 2.2

サンプルコード

/app/Http/Controllers/Auth/AuthenticatedSessionController.php

/app/Http/Controllers/Auth/AuthenticatedSessionController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use App\Http\Requests\Auth\LoginRequest;
7+use Illuminate\Http\RedirectResponse;
8+use Illuminate\Http\Request;
9+use Illuminate\Support\Facades\Auth;
10+use Illuminate\View\View;
11+
12+class AuthenticatedSessionController extends Controller
13+{
14+    /**
15+     * Display the login view.
16+     */
17+    public function create(): View
18+    {
19+        return view('auth.login');
20+    }
21+
22+    /**
23+     * Handle an incoming authentication request.
24+     */
25+    public function store(LoginRequest $request): RedirectResponse
26+    {
27+        $request->authenticate();
28+
29+        $request->session()->regenerate();
30+
31+        // Login後のリダイレクト先をposts.indexに変更
32+        return redirect()->route('posts.index');
33+
34+        // return redirect()->intended(route('dashboard', absolute: false));
35+    }
36+
37+    /**
38+     * Destroy an authenticated session.
39+     */
40+    public function destroy(Request $request): RedirectResponse
41+    {
42+        Auth::guard('web')->logout();
43+
44+        $request->session()->invalidate();
45+
46+        $request->session()->regenerateToken();
47+
48+        return redirect('/');
49+    }
50+}
51

/app/Http/Controllers/Auth/ConfirmablePasswordController.php

/app/Http/Controllers/Auth/ConfirmablePasswordController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use Illuminate\Http\RedirectResponse;
7+use Illuminate\Http\Request;
8+use Illuminate\Support\Facades\Auth;
9+use Illuminate\Validation\ValidationException;
10+use Illuminate\View\View;
11+
12+class ConfirmablePasswordController extends Controller
13+{
14+    /**
15+     * Show the confirm password view.
16+     */
17+    public function show(): View
18+    {
19+        return view('auth.confirm-password');
20+    }
21+
22+    /**
23+     * Confirm the user's password.
24+     */
25+    public function store(Request $request): RedirectResponse
26+    {
27+        if (! Auth::guard('web')->validate([
28+            'email' => $request->user()->email,
29+            'password' => $request->password,
30+        ])) {
31+            throw ValidationException::withMessages([
32+                'password' => __('auth.password'),
33+            ]);
34+        }
35+
36+        $request->session()->put('auth.password_confirmed_at', time());
37+
38+        return redirect()->intended(route('dashboard', absolute: false));
39+    }
40+}
41

/app/Http/Controllers/Auth/EmailVerificationNotificationController.php

/app/Http/Controllers/Auth/EmailVerificationNotificationController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use Illuminate\Http\RedirectResponse;
7+use Illuminate\Http\Request;
8+
9+class EmailVerificationNotificationController extends Controller
10+{
11+    /**
12+     * Send a new email verification notification.
13+     */
14+    public function store(Request $request): RedirectResponse
15+    {
16+        if ($request->user()->hasVerifiedEmail()) {
17+            return redirect()->intended(route('dashboard', absolute: false));
18+        }
19+
20+        $request->user()->sendEmailVerificationNotification();
21+
22+        return back()->with('status', 'verification-link-sent');
23+    }
24+}
25

/app/Http/Controllers/Auth/EmailVerificationPromptController.php

/app/Http/Controllers/Auth/EmailVerificationPromptController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use Illuminate\Http\RedirectResponse;
7+use Illuminate\Http\Request;
8+use Illuminate\View\View;
9+
10+class EmailVerificationPromptController extends Controller
11+{
12+    /**
13+     * Display the email verification prompt.
14+     */
15+    public function __invoke(Request $request): RedirectResponse|View
16+    {
17+        return $request->user()->hasVerifiedEmail()
18+                    ? redirect()->intended(route('dashboard', absolute: false))
19+                    : view('auth.verify-email');
20+    }
21+}
22

/app/Http/Controllers/Auth/NewPasswordController.php

/app/Http/Controllers/Auth/NewPasswordController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use Illuminate\Auth\Events\PasswordReset;
7+use Illuminate\Http\RedirectResponse;
8+use Illuminate\Http\Request;
9+use Illuminate\Support\Facades\Hash;
10+use Illuminate\Support\Facades\Password;
11+use Illuminate\Support\Str;
12+use Illuminate\Validation\Rules;
13+use Illuminate\View\View;
14+
15+class NewPasswordController extends Controller
16+{
17+    /**
18+     * Display the password reset view.
19+     */
20+    public function create(Request $request): View
21+    {
22+        return view('auth.reset-password', ['request' => $request]);
23+    }
24+
25+    /**
26+     * Handle an incoming new password request.
27+     *
28+     * @throws \Illuminate\Validation\ValidationException
29+     */
30+    public function store(Request $request): RedirectResponse
31+    {
32+        $request->validate([
33+            'token' => ['required'],
34+            'email' => ['required', 'email'],
35+            'password' => ['required', 'confirmed', Rules\Password::defaults()],
36+        ]);
37+
38+        // Here we will attempt to reset the user's password. If it is successful we
39+        // will update the password on an actual user model and persist it to the
40+        // database. Otherwise we will parse the error and return the response.
41+        $status = Password::reset(
42+            $request->only('email', 'password', 'password_confirmation', 'token'),
43+            function ($user) use ($request) {
44+                $user->forceFill([
45+                    'password' => Hash::make($request->password),
46+                    'remember_token' => Str::random(60),
47+                ])->save();
48+
49+                event(new PasswordReset($user));
50+            }
51+        );
52+
53+        // If the password was successfully reset, we will redirect the user back to
54+        // the application's home authenticated view. If there is an error we can
55+        // redirect them back to where they came from with their error message.
56+        return $status == Password::PASSWORD_RESET
57+                    ? redirect()->route('login')->with('status', __($status))
58+                    : back()->withInput($request->only('email'))
59+                        ->withErrors(['email' => __($status)]);
60+    }
61+}
62

/app/Http/Controllers/Auth/PasswordController.php

/app/Http/Controllers/Auth/PasswordController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use Illuminate\Http\RedirectResponse;
7+use Illuminate\Http\Request;
8+use Illuminate\Support\Facades\Hash;
9+use Illuminate\Validation\Rules\Password;
10+
11+class PasswordController extends Controller
12+{
13+    /**
14+     * Update the user's password.
15+     */
16+    public function update(Request $request): RedirectResponse
17+    {
18+        $validated = $request->validateWithBag('updatePassword', [
19+            'current_password' => ['required', 'current_password'],
20+            'password' => ['required', Password::defaults(), 'confirmed'],
21+        ]);
22+
23+        $request->user()->update([
24+            'password' => Hash::make($validated['password']),
25+        ]);
26+
27+        return back()->with('status', 'password-updated');
28+    }
29+}
30

/app/Http/Controllers/Auth/PasswordResetLinkController.php

/app/Http/Controllers/Auth/PasswordResetLinkController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use Illuminate\Http\RedirectResponse;
7+use Illuminate\Http\Request;
8+use Illuminate\Support\Facades\Password;
9+use Illuminate\View\View;
10+
11+class PasswordResetLinkController extends Controller
12+{
13+    /**
14+     * Display the password reset link request view.
15+     */
16+    public function create(): View
17+    {
18+        return view('auth.forgot-password');
19+    }
20+
21+    /**
22+     * Handle an incoming password reset link request.
23+     *
24+     * @throws \Illuminate\Validation\ValidationException
25+     */
26+    public function store(Request $request): RedirectResponse
27+    {
28+        $request->validate([
29+            'email' => ['required', 'email'],
30+        ]);
31+
32+        // We will send the password reset link to this user. Once we have attempted
33+        // to send the link, we will examine the response then see the message we
34+        // need to show to the user. Finally, we'll send out a proper response.
35+        $status = Password::sendResetLink(
36+            $request->only('email')
37+        );
38+
39+        return $status == Password::RESET_LINK_SENT
40+                    ? back()->with('status', __($status))
41+                    : back()->withInput($request->only('email'))
42+                        ->withErrors(['email' => __($status)]);
43+    }
44+}
45

/app/Http/Controllers/Auth/RegisteredUserController.php

/app/Http/Controllers/Auth/RegisteredUserController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use App\Models\User;
7+use Illuminate\Auth\Events\Registered;
8+use Illuminate\Http\RedirectResponse;
9+use Illuminate\Http\Request;
10+use Illuminate\Support\Facades\Auth;
11+use Illuminate\Support\Facades\Hash;
12+use Illuminate\Validation\Rules;
13+use Illuminate\View\View;
14+
15+class RegisteredUserController extends Controller
16+{
17+    /**
18+     * Display the registration view.
19+     */
20+    public function create(): View
21+    {
22+        return view('auth.register');
23+    }
24+
25+    /**
26+     * Handle an incoming registration request.
27+     *
28+     * @throws \Illuminate\Validation\ValidationException
29+     */
30+    public function store(Request $request): RedirectResponse
31+    {
32+        $request->validate([
33+            'name' => ['required', 'string', 'max:255'],
34+            'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
35+            'password' => ['required', 'confirmed', Rules\Password::defaults()],
36+        ]);
37+
38+        $user = User::create([
39+            'name' => $request->name,
40+            'email' => $request->email,
41+            'password' => Hash::make($request->password),
42+        ]);
43+
44+        event(new Registered($user));
45+
46+        Auth::login($user);
47+
48+        // 新規登録後にリダイレクトするURLを指定
49+        return redirect()->route('posts.index');
50+
51+        // return redirect(route('dashboard', absolute: false));
52+    }
53+}
54

/app/Http/Controllers/Auth/VerifyEmailController.php

/app/Http/Controllers/Auth/VerifyEmailController.php
1+<?php
2+
3+namespace App\Http\Controllers\Auth;
4+
5+use App\Http\Controllers\Controller;
6+use Illuminate\Auth\Events\Verified;
7+use Illuminate\Foundation\Auth\EmailVerificationRequest;
8+use Illuminate\Http\RedirectResponse;
9+
10+class VerifyEmailController extends Controller
11+{
12+    /**
13+     * Mark the authenticated user's email address as verified.
14+     */
15+    public function __invoke(EmailVerificationRequest $request): RedirectResponse
16+    {
17+        if ($request->user()->hasVerifiedEmail()) {
18+            return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
19+        }
20+
21+        if ($request->user()->markEmailAsVerified()) {
22+            event(new Verified($request->user()));
23+        }
24+
25+        return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
26+    }
27+}
28

/app/Http/Controllers/PostController.php

/app/Http/Controllers/PostController.php
1 
2 use Illuminate\Http\Request;
3 use App\Models\Post;
4+use Illuminate\Support\Facades\Auth;
5 
6 class PostController extends Controller
7 {
8      */
9     public function create()
10     {
11+        // ログインユーザーのみが新規作成ページにアクセス可能
12+        if (!Auth::check()) {
13+            return redirect()->route('login');
14+        }
15+
16         return view('posts.create');
17     }
18 
19      */
20     public function store(Request $request)
21     {
22+        // ログインユーザーのみが投稿可能
23+        if (!Auth::check()) {
24+            return redirect()->route('login');
25+        }
26+
27         $request->validate([
28             'title' => 'required|max:255',
29             'content' => 'required'
30         ]);
31 
32-        $post = Post::create($request->all());
33+        $post = Post::create([
34+            'title' => $request->title,
35+            'content' => $request->content,
36+            'user_id' => auth()->id()
37+        ]);
38 
39         return redirect()->route('posts.show', ['post' => $post->id]);
40     }
41     public function edit(string $id)
42     {
43         $post = Post::findOrFail($id);
44+
45+        // 所有者のみ編集ページにアクセス可能
46+        if (Auth::id() !== $post->user_id) {
47+            return redirect()->route('posts.index');
48+        }
49+
50         return view('posts.edit', compact('post'));
51     }
52 
53 
54         $post = Post::findOrFail($id);
55 
56+        // 所有者のみ更新可能
57+        if (Auth::id() !== $post->user_id) {
58+            return redirect()->route('posts.index');
59+        }
60+
61         $post->update([
62             'title' => $request->title,
63             'content' => $request->content
64     public function destroy(string $id)
65     {
66         $post = Post::findOrFail($id);
67+
68+        // 所有者のみ削除可能
69+        if (Auth::id() !== $post->user_id) {
70+            return redirect()->route('posts.index');
71+        }
72+
73         $post->delete();
74+
75         return redirect()->route('posts.index');
76     }
77 }
78

/app/Http/Controllers/ProfileController.php

/app/Http/Controllers/ProfileController.php
1+<?php
2+
3+namespace App\Http\Controllers;
4+
5+use App\Http\Requests\ProfileUpdateRequest;
6+use Illuminate\Http\RedirectResponse;
7+use Illuminate\Http\Request;
8+use Illuminate\Support\Facades\Auth;
9+use Illuminate\Support\Facades\Redirect;
10+use Illuminate\View\View;
11+
12+class ProfileController extends Controller
13+{
14+    /**
15+     * Display the user's profile form.
16+     */
17+    public function edit(Request $request): View
18+    {
19+        return view('profile.edit', [
20+            'user' => $request->user(),
21+        ]);
22+    }
23+
24+    /**
25+     * Update the user's profile information.
26+     */
27+    public function update(ProfileUpdateRequest $request): RedirectResponse
28+    {
29+        $request->user()->fill($request->validated());
30+
31+        if ($request->user()->isDirty('email')) {
32+            $request->user()->email_verified_at = null;
33+        }
34+
35+        $request->user()->save();
36+
37+        return Redirect::route('profile.edit')->with('status', 'profile-updated');
38+    }
39+
40+    /**
41+     * Delete the user's account.
42+     */
43+    public function destroy(Request $request): RedirectResponse
44+    {
45+        $request->validateWithBag('userDeletion', [
46+            'password' => ['required', 'current_password'],
47+        ]);
48+
49+        $user = $request->user();
50+
51+        Auth::logout();
52+
53+        $user->delete();
54+
55+        $request->session()->invalidate();
56+        $request->session()->regenerateToken();
57+
58+        return Redirect::to('/');
59+    }
60+}
61

/app/Http/Requests/Auth/LoginRequest.php

/app/Http/Requests/Auth/LoginRequest.php
1+<?php
2+
3+namespace App\Http\Requests\Auth;
4+
5+use Illuminate\Auth\Events\Lockout;
6+use Illuminate\Foundation\Http\FormRequest;
7+use Illuminate\Support\Facades\Auth;
8+use Illuminate\Support\Facades\RateLimiter;
9+use Illuminate\Support\Str;
10+use Illuminate\Validation\ValidationException;
11+
12+class LoginRequest extends FormRequest
13+{
14+    /**
15+     * Determine if the user is authorized to make this request.
16+     */
17+    public function authorize(): bool
18+    {
19+        return true;
20+    }
21+
22+    /**
23+     * Get the validation rules that apply to the request.
24+     *
25+     * @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
26+     */
27+    public function rules(): array
28+    {
29+        return [
30+            'email' => ['required', 'string', 'email'],
31+            'password' => ['required', 'string'],
32+        ];
33+    }
34+
35+    /**
36+     * Attempt to authenticate the request's credentials.
37+     *
38+     * @throws \Illuminate\Validation\ValidationException
39+     */
40+    public function authenticate(): void
41+    {
42+        $this->ensureIsNotRateLimited();
43+
44+        if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
45+            RateLimiter::hit($this->throttleKey());
46+
47+            throw ValidationException::withMessages([
48+                'email' => trans('auth.failed'),
49+            ]);
50+        }
51+
52+        RateLimiter::clear($this->throttleKey());
53+    }
54+
55+    /**
56+     * Ensure the login request is not rate limited.
57+     *
58+     * @throws \Illuminate\Validation\ValidationException
59+     */
60+    public function ensureIsNotRateLimited(): void
61+    {
62+        if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
63+            return;
64+        }
65+
66+        event(new Lockout($this));
67+
68+        $seconds = RateLimiter::availableIn($this->throttleKey());
69+
70+        throw ValidationException::withMessages([
71+            'email' => trans('auth.throttle', [
72+                'seconds' => $seconds,
73+                'minutes' => ceil($seconds / 60),
74+            ]),
75+        ]);
76+    }
77+
78+    /**
79+     * Get the rate limiting throttle key for the request.
80+     */
81+    public function throttleKey(): string
82+    {
83+        return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
84+    }
85+}
86

/app/Http/Requests/ProfileUpdateRequest.php

/app/Http/Requests/ProfileUpdateRequest.php
1+<?php
2+
3+namespace App\Http\Requests;
4+
5+use App\Models\User;
6+use Illuminate\Foundation\Http\FormRequest;
7+use Illuminate\Validation\Rule;
8+
9+class ProfileUpdateRequest extends FormRequest
10+{
11+    /**
12+     * Get the validation rules that apply to the request.
13+     *
14+     * @return array<string, \Illuminate\Contracts\Validation\Rule|array|string>
15+     */
16+    public function rules(): array
17+    {
18+        return [
19+            'name' => ['required', 'string', 'max:255'],
20+            'email' => [
21+                'required',
22+                'string',
23+                'lowercase',
24+                'email',
25+                'max:255',
26+                Rule::unique(User::class)->ignore($this->user()->id),
27+            ],
28+        ];
29+    }
30+}
31

/app/Models/Post.php

/app/Models/Post.php
1 {
2     protected $fillable = [
3         'title',
4-        'content'
5+        'content',
6+        'user_id'
7     ];
8 }
9

/app/View/Components/AppLayout.php

/app/View/Components/AppLayout.php
1+<?php
2+
3+namespace App\View\Components;
4+
5+use Illuminate\View\Component;
6+use Illuminate\View\View;
7+
8+class AppLayout extends Component
9+{
10+    /**
11+     * Get the view / contents that represents the component.
12+     */
13+    public function render(): View
14+    {
15+        return view('layouts.app');
16+    }
17+}
18

/app/View/Components/GuestLayout.php

/app/View/Components/GuestLayout.php
1+<?php
2+
3+namespace App\View\Components;
4+
5+use Illuminate\View\Component;
6+use Illuminate\View\View;
7+
8+class GuestLayout extends Component
9+{
10+    /**
11+     * Get the view / contents that represents the component.
12+     */
13+    public function render(): View
14+    {
15+        return view('layouts.guest');
16+    }
17+}
18

/composer.json

/composer.json
1     },
2     "require-dev": {
3         "fakerphp/faker": "^1.23",
4+        "laravel/breeze": "^2.2",
5         "laravel/pail": "^1.1",
6         "laravel/pint": "^1.13",
7         "laravel/sail": "^1.26",
8

/database/migrations/2024_10_26_120312_add_user_id_to_posts_table.php

/database/migrations/2024_10_26_120312_add_user_id_to_posts_table.php
1+<?php
2+
3+use Illuminate\Database\Migrations\Migration;
4+use Illuminate\Database\Schema\Blueprint;
5+use Illuminate\Support\Facades\Schema;
6+
7+return new class extends Migration
8+{
9+    /**
10+     * Run the migrations.
11+     */
12+    public function up(): void
13+    {
14+        Schema::table('posts', function (Blueprint $table) {
15+            $table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
16+        });
17+    }
18+
19+    /**
20+     * Reverse the migrations.
21+     */
22+    public function down(): void
23+    {
24+        Schema::table('posts', function (Blueprint $table) {
25+            $table->dropForeign(['user_id']);
26+            $table->dropColumn('user_id');
27+        });
28+    }
29+};
30

/package.json

/package.json
1         "dev": "vite"
2     },
3     "devDependencies": {
4-        "autoprefixer": "^10.4.20",
5+        "@tailwindcss/forms": "^0.5.2",
6+        "alpinejs": "^3.4.2",
7+        "autoprefixer": "^10.4.2",
8         "axios": "^1.7.4",
9         "concurrently": "^9.0.1",
10         "laravel-vite-plugin": "^1.0",
11-        "postcss": "^8.4.47",
12-        "tailwindcss": "^3.4.13",
13+        "postcss": "^8.4.31",
14+        "tailwindcss": "^3.1.0",
15         "vite": "^5.0"
16     }
17 }
18

/resources/js/app.js

/resources/js/app.js
1 import './bootstrap';
2+
3+import Alpine from 'alpinejs';
4+
5+window.Alpine = Alpine;
6+
7+Alpine.start();
8

/resources/views/auth/confirm-password.blade.php

/resources/views/auth/confirm-password.blade.php
1+<x-guest-layout>
2+    <div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
3+        {{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
4+    </div>
5+
6+    <form method="POST" action="{{ route('password.confirm') }}">
7+        @csrf
8+
9+        <!-- Password -->
10+        <div>
11+            <x-input-label for="password" :value="__('Password')" />
12+
13+            <x-text-input id="password" class="block mt-1 w-full"
14+                            type="password"
15+                            name="password"
16+                            required autocomplete="current-password" />
17+
18+            <x-input-error :messages="$errors->get('password')" class="mt-2" />
19+        </div>
20+
21+        <div class="flex justify-end mt-4">
22+            <x-primary-button>
23+                {{ __('Confirm') }}
24+            </x-primary-button>
25+        </div>
26+    </form>
27+</x-guest-layout>
28

/resources/views/auth/forgot-password.blade.php

/resources/views/auth/forgot-password.blade.php
1+<x-guest-layout>
2+    <div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
3+        {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
4+    </div>
5+
6+    <!-- Session Status -->
7+    <x-auth-session-status class="mb-4" :status="session('status')" />
8+
9+    <form method="POST" action="{{ route('password.email') }}">
10+        @csrf
11+
12+        <!-- Email Address -->
13+        <div>
14+            <x-input-label for="email" :value="__('Email')" />
15+            <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
16+            <x-input-error :messages="$errors->get('email')" class="mt-2" />
17+        </div>
18+
19+        <div class="flex items-center justify-end mt-4">
20+            <x-primary-button>
21+                {{ __('Email Password Reset Link') }}
22+            </x-primary-button>
23+        </div>
24+    </form>
25+</x-guest-layout>
26

/resources/views/auth/login.blade.php

/resources/views/auth/login.blade.php
1+<x-guest-layout>
2+    <!-- Session Status -->
3+    <x-auth-session-status class="mb-4" :status="session('status')" />
4+
5+    <form method="POST" action="{{ route('login') }}">
6+        @csrf
7+
8+        <!-- Email Address -->
9+        <div>
10+            <x-input-label for="email" :value="__('Email')" />
11+            <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
12+            <x-input-error :messages="$errors->get('email')" class="mt-2" />
13+        </div>
14+
15+        <!-- Password -->
16+        <div class="mt-4">
17+            <x-input-label for="password" :value="__('Password')" />
18+
19+            <x-text-input id="password" class="block mt-1 w-full"
20+                            type="password"
21+                            name="password"
22+                            required autocomplete="current-password" />
23+
24+            <x-input-error :messages="$errors->get('password')" class="mt-2" />
25+        </div>
26+
27+        <!-- Remember Me -->
28+        <div class="block mt-4">
29+            <label for="remember_me" class="inline-flex items-center">
30+                <input id="remember_me" type="checkbox" class="rounded dark:bg-gray-900 border-gray-300 dark:border-gray-700 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:focus:ring-offset-gray-800" name="remember">
31+                <span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ __('Remember me') }}</span>
32+            </label>
33+        </div>
34+
35+        <div class="flex items-center justify-end mt-4">
36+            @if (Route::has('password.request'))
37+                <a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('password.request') }}">
38+                    {{ __('Forgot your password?') }}
39+                </a>
40+            @endif
41+
42+            <x-primary-button class="ms-3">
43+                {{ __('Log in') }}
44+            </x-primary-button>
45+        </div>
46+    </form>
47+</x-guest-layout>
48

/resources/views/auth/register.blade.php

/resources/views/auth/register.blade.php
1+<x-guest-layout>
2+    <form method="POST" action="{{ route('register') }}">
3+        @csrf
4+
5+        <!-- Name -->
6+        <div>
7+            <x-input-label for="name" :value="__('Name')" />
8+            <x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
9+            <x-input-error :messages="$errors->get('name')" class="mt-2" />
10+        </div>
11+
12+        <!-- Email Address -->
13+        <div class="mt-4">
14+            <x-input-label for="email" :value="__('Email')" />
15+            <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
16+            <x-input-error :messages="$errors->get('email')" class="mt-2" />
17+        </div>
18+
19+        <!-- Password -->
20+        <div class="mt-4">
21+            <x-input-label for="password" :value="__('Password')" />
22+
23+            <x-text-input id="password" class="block mt-1 w-full"
24+                            type="password"
25+                            name="password"
26+                            required autocomplete="new-password" />
27+
28+            <x-input-error :messages="$errors->get('password')" class="mt-2" />
29+        </div>
30+
31+        <!-- Confirm Password -->
32+        <div class="mt-4">
33+            <x-input-label for="password_confirmation" :value="__('Confirm Password')" />
34+
35+            <x-text-input id="password_confirmation" class="block mt-1 w-full"
36+                            type="password"
37+                            name="password_confirmation" required autocomplete="new-password" />
38+
39+            <x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
40+        </div>
41+
42+        <div class="flex items-center justify-end mt-4">
43+            <a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}">
44+                {{ __('Already registered?') }}
45+            </a>
46+
47+            <x-primary-button class="ms-4">
48+                {{ __('Register') }}
49+            </x-primary-button>
50+        </div>
51+    </form>
52+</x-guest-layout>
53

/resources/views/auth/reset-password.blade.php

/resources/views/auth/reset-password.blade.php
1+<x-guest-layout>
2+    <form method="POST" action="{{ route('password.store') }}">
3+        @csrf
4+
5+        <!-- Password Reset Token -->
6+        <input type="hidden" name="token" value="{{ $request->route('token') }}">
7+
8+        <!-- Email Address -->
9+        <div>
10+            <x-input-label for="email" :value="__('Email')" />
11+            <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
12+            <x-input-error :messages="$errors->get('email')" class="mt-2" />
13+        </div>
14+
15+        <!-- Password -->
16+        <div class="mt-4">
17+            <x-input-label for="password" :value="__('Password')" />
18+            <x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
19+            <x-input-error :messages="$errors->get('password')" class="mt-2" />
20+        </div>
21+
22+        <!-- Confirm Password -->
23+        <div class="mt-4">
24+            <x-input-label for="password_confirmation" :value="__('Confirm Password')" />
25+
26+            <x-text-input id="password_confirmation" class="block mt-1 w-full"
27+                                type="password"
28+                                name="password_confirmation" required autocomplete="new-password" />
29+
30+            <x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
31+        </div>
32+
33+        <div class="flex items-center justify-end mt-4">
34+            <x-primary-button>
35+                {{ __('Reset Password') }}
36+            </x-primary-button>
37+        </div>
38+    </form>
39+</x-guest-layout>
40

/resources/views/auth/verify-email.blade.php

/resources/views/auth/verify-email.blade.php
1+<x-guest-layout>
2+    <div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
3+        {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
4+    </div>
5+
6+    @if (session('status') == 'verification-link-sent')
7+        <div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
8+            {{ __('A new verification link has been sent to the email address you provided during registration.') }}
9+        </div>
10+    @endif
11+
12+    <div class="mt-4 flex items-center justify-between">
13+        <form method="POST" action="{{ route('verification.send') }}">
14+            @csrf
15+
16+            <div>
17+                <x-primary-button>
18+                    {{ __('Resend Verification Email') }}
19+                </x-primary-button>
20+            </div>
21+        </form>
22+
23+        <form method="POST" action="{{ route('logout') }}">
24+            @csrf
25+
26+            <button type="submit" class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
27+                {{ __('Log Out') }}
28+            </button>
29+        </form>
30+    </div>
31+</x-guest-layout>
32

/resources/views/components/application-logo.blade.php

/resources/views/components/application-logo.blade.php
1+<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
2+    <path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
3+</svg>
4

/resources/views/components/auth-session-status.blade.php

/resources/views/components/auth-session-status.blade.php
1+@props(['status'])
2+
3+@if ($status)
4+    <div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600 dark:text-green-400']) }}>
5+        {{ $status }}
6+    </div>
7+@endif
8

/resources/views/components/danger-button.blade.php

/resources/views/components/danger-button.blade.php
1+<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150']) }}>
2+    {{ $slot }}
3+</button>
4

/resources/views/components/dropdown-link.blade.php

/resources/views/components/dropdown-link.blade.php
1+<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
2

/resources/views/components/dropdown.blade.php

/resources/views/components/dropdown.blade.php
1+@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-gray-700'])
2+
3+@php
4+$alignmentClasses = match ($align) {
5+    'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
6+    'top' => 'origin-top',
7+    default => 'ltr:origin-top-right rtl:origin-top-left end-0',
8+};
9+
10+$width = match ($width) {
11+    '48' => 'w-48',
12+    default => $width,
13+};
14+@endphp
15+
16+<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
17+    <div @click="open = ! open">
18+        {{ $trigger }}
19+    </div>
20+
21+    <div x-show="open"
22+            x-transition:enter="transition ease-out duration-200"
23+            x-transition:enter-start="opacity-0 scale-95"
24+            x-transition:enter-end="opacity-100 scale-100"
25+            x-transition:leave="transition ease-in duration-75"
26+            x-transition:leave-start="opacity-100 scale-100"
27+            x-transition:leave-end="opacity-0 scale-95"
28+            class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
29+            style="display: none;"
30+            @click="open = false">
31+        <div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}">
32+            {{ $content }}
33+        </div>
34+    </div>
35+</div>
36

/resources/views/components/input-error.blade.php

/resources/views/components/input-error.blade.php
1+@props(['messages'])
2+
3+@if ($messages)
4+    <ul {{ $attributes->merge(['class' => 'text-sm text-red-600 dark:text-red-400 space-y-1']) }}>
5+        @foreach ((array) $messages as $message)
6+            <li>{{ $message }}</li>
7+        @endforeach
8+    </ul>
9+@endif
10

/resources/views/components/input-label.blade.php

/resources/views/components/input-label.blade.php
1+@props(['value'])
2+
3+<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700 dark:text-gray-300']) }}>
4+    {{ $value ?? $slot }}
5+</label>
6

/resources/views/components/modal.blade.php

/resources/views/components/modal.blade.php
1+@props([
2+    'name',
3+    'show' => false,
4+    'maxWidth' => '2xl'
5+])
6+
7+@php
8+$maxWidth = [
9+    'sm' => 'sm:max-w-sm',
10+    'md' => 'sm:max-w-md',
11+    'lg' => 'sm:max-w-lg',
12+    'xl' => 'sm:max-w-xl',
13+    '2xl' => 'sm:max-w-2xl',
14+][$maxWidth];
15+@endphp
16+
17+<div
18+    x-data="{
19+        show: @js($show),
20+        focusables() {
21+            // All focusable element types...
22+            let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
23+            return [...$el.querySelectorAll(selector)]
24+                // All non-disabled elements...
25+                .filter(el => ! el.hasAttribute('disabled'))
26+        },
27+        firstFocusable() { return this.focusables()[0] },
28+        lastFocusable() { return this.focusables().slice(-1)[0] },
29+        nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
30+        prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
31+        nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
32+        prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
33+    }"
34+    x-init="$watch('show', value => {
35+        if (value) {
36+            document.body.classList.add('overflow-y-hidden');
37+            {{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
38+        } else {
39+            document.body.classList.remove('overflow-y-hidden');
40+        }
41+    })"
42+    x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
43+    x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
44+    x-on:close.stop="show = false"
45+    x-on:keydown.escape.window="show = false"
46+    x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
47+    x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
48+    x-show="show"
49+    class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
50+    style="display: {{ $show ? 'block' : 'none' }};"
51+>
52+    <div
53+        x-show="show"
54+        class="fixed inset-0 transform transition-all"
55+        x-on:click="show = false"
56+        x-transition:enter="ease-out duration-300"
57+        x-transition:enter-start="opacity-0"
58+        x-transition:enter-end="opacity-100"
59+        x-transition:leave="ease-in duration-200"
60+        x-transition:leave-start="opacity-100"
61+        x-transition:leave-end="opacity-0"
62+    >
63+        <div class="absolute inset-0 bg-gray-500 dark:bg-gray-900 opacity-75"></div>
64+    </div>
65+
66+    <div
67+        x-show="show"
68+        class="mb-6 bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
69+        x-transition:enter="ease-out duration-300"
70+        x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
71+        x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
72+        x-transition:leave="ease-in duration-200"
73+        x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
74+        x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
75+    >
76+        {{ $slot }}
77+    </div>
78+</div>
79

/resources/views/components/nav-link.blade.php

/resources/views/components/nav-link.blade.php
1+@props(['active'])
2+
3+@php
4+$classes = ($active ?? false)
5+            ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
6+            : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out';
7+@endphp
8+
9+<a {{ $attributes->merge(['class' => $classes]) }}>
10+    {{ $slot }}
11+</a>
12

/resources/views/components/primary-button.blade.php

/resources/views/components/primary-button.blade.php
1+<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150']) }}>
2+    {{ $slot }}
3+</button>
4

/resources/views/components/responsive-nav-link.blade.php

/resources/views/components/responsive-nav-link.blade.php
1+@props(['active'])
2+
3+@php
4+$classes = ($active ?? false)
5+            ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
6+            : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out';
7+@endphp
8+
9+<a {{ $attributes->merge(['class' => $classes]) }}>
10+    {{ $slot }}
11+</a>
12

/resources/views/components/secondary-button.blade.php

/resources/views/components/secondary-button.blade.php
1+<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-500 rounded-md font-semibold text-xs text-gray-700 dark:text-gray-300 uppercase tracking-widest shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150']) }}>
2+    {{ $slot }}
3+</button>
4

/resources/views/components/text-input.blade.php

/resources/views/components/text-input.blade.php
1+@props(['disabled' => false])
2+
3+<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm']) }}>
4

/resources/views/dashboard.blade.php

/resources/views/dashboard.blade.php
1+<x-app-layout>
2+    <x-slot name="header">
3+        <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
4+            {{ __('Dashboard') }}
5+        </h2>
6+    </x-slot>
7+
8+    <div class="py-12">
9+        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
10+            <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
11+                <div class="p-6 text-gray-900 dark:text-gray-100">
12+                    {{ __("You're logged in!") }}
13+                </div>
14+            </div>
15+        </div>
16+    </div>
17+</x-app-layout>
18

/resources/views/layout.blade.php

/resources/views/layout.blade.php
1     <meta name="viewport" content="width=device-width, initial-scale=1.0">
2     <title>掲示板アプリ</title>
3     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
4+    {{-- <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
5+    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.0.0/dist/umd/popper.min.js"></script>
6+    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script> --}}
7 </head>
8 <body>
9-    @yield('content')
10+    <nav class="navbar navbar-expand-lg bg-primary">
11+        <div class="container">
12+            <a class="navbar-brand text-white" href="{{ route('posts.index') }}">掲示板アプリ</a>
13+            <div class="ml-auto">
14+                <ul class="navbar-nav">
15+                    @if (Auth::check())
16+                        <li class="nav-item">
17+                            <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
18+                                @csrf
19+                            </form>
20+                            <a class="nav-link text-white" href="#" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">ログアウト</a>
21+                        </li>
22+                    @else
23+                        <li class="nav-item">
24+                            <a class="nav-link text-white" href="{{ route('login') }}">ログイン</a>
25+                        </li>
26+                        <li class="nav-item">
27+                            <a class="nav-link text-white" href="{{ route('register') }}">新規登録</a>
28+                        </li>
29+                    @endif
30+                </ul>
31+            </div>
32+        </div>
33+    </nav>
34+    <div class="container mt-4">
35+        @yield('content')
36+    </div>
37 </body>
38 </html>
39

/resources/views/layouts/app.blade.php

/resources/views/layouts/app.blade.php
1+<!DOCTYPE html>
2+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
3+    <head>
4+        <meta charset="utf-8">
5+        <meta name="viewport" content="width=device-width, initial-scale=1">
6+        <meta name="csrf-token" content="{{ csrf_token() }}">
7+
8+        <title>{{ config('app.name', 'Laravel') }}</title>
9+
10+        <!-- Fonts -->
11+        <link rel="preconnect" href="https://fonts.bunny.net">
12+        <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
13+
14+        <!-- Scripts -->
15+        @vite(['resources/css/app.css', 'resources/js/app.js'])
16+    </head>
17+    <body class="font-sans antialiased">
18+        <div class="min-h-screen bg-gray-100 dark:bg-gray-900">
19+            @include('layouts.navigation')
20+
21+            <!-- Page Heading -->
22+            @isset($header)
23+                <header class="bg-white dark:bg-gray-800 shadow">
24+                    <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
25+                        {{ $header }}
26+                    </div>
27+                </header>
28+            @endisset
29+
30+            <!-- Page Content -->
31+            <main>
32+                {{ $slot }}
33+            </main>
34+        </div>
35+    </body>
36+</html>
37

/resources/views/layouts/guest.blade.php

/resources/views/layouts/guest.blade.php
1+<!DOCTYPE html>
2+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
3+    <head>
4+        <meta charset="utf-8">
5+        <meta name="viewport" content="width=device-width, initial-scale=1">
6+        <meta name="csrf-token" content="{{ csrf_token() }}">
7+
8+        <title>{{ config('app.name', 'Laravel') }}</title>
9+
10+        <!-- Fonts -->
11+        <link rel="preconnect" href="https://fonts.bunny.net">
12+        <link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
13+
14+        <!-- Scripts -->
15+        @vite(['resources/css/app.css', 'resources/js/app.js'])
16+    </head>
17+    <body class="font-sans text-gray-900 antialiased">
18+        <div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
19+            <div>
20+                <a href="/">
21+                    <x-application-logo class="w-20 h-20 fill-current text-gray-500" />
22+                </a>
23+            </div>
24+
25+            <div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
26+                {{ $slot }}
27+            </div>
28+        </div>
29+    </body>
30+</html>
31

/resources/views/layouts/navigation.blade.php

/resources/views/layouts/navigation.blade.php
1+<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
2+    <!-- Primary Navigation Menu -->
3+    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
4+        <div class="flex justify-between h-16">
5+            <div class="flex">
6+                <!-- Logo -->
7+                <div class="shrink-0 flex items-center">
8+                    <a href="{{ route('dashboard') }}">
9+                        <x-application-logo class="block h-9 w-auto fill-current text-gray-800 dark:text-gray-200" />
10+                    </a>
11+                </div>
12+
13+                <!-- Navigation Links -->
14+                <div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
15+                    <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
16+                        {{ __('Dashboard') }}
17+                    </x-nav-link>
18+                </div>
19+            </div>
20+
21+            <!-- Settings Dropdown -->
22+            <div class="hidden sm:flex sm:items-center sm:ms-6">
23+                <x-dropdown align="right" width="48">
24+                    <x-slot name="trigger">
25+                        <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
26+                            <div>{{ Auth::user()->name }}</div>
27+
28+                            <div class="ms-1">
29+                                <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
30+                                    <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
31+                                </svg>
32+                            </div>
33+                        </button>
34+                    </x-slot>
35+
36+                    <x-slot name="content">
37+                        <x-dropdown-link :href="route('profile.edit')">
38+                            {{ __('Profile') }}
39+                        </x-dropdown-link>
40+
41+                        <!-- Authentication -->
42+                        <form method="POST" action="{{ route('logout') }}">
43+                            @csrf
44+
45+                            <x-dropdown-link :href="route('logout')"
46+                                    onclick="event.preventDefault();
47+                                                this.closest('form').submit();">
48+                                {{ __('Log Out') }}
49+                            </x-dropdown-link>
50+                        </form>
51+                    </x-slot>
52+                </x-dropdown>
53+            </div>
54+
55+            <!-- Hamburger -->
56+            <div class="-me-2 flex items-center sm:hidden">
57+                <button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out">
58+                    <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
59+                        <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
60+                        <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
61+                    </svg>
62+                </button>
63+            </div>
64+        </div>
65+    </div>
66+
67+    <!-- Responsive Navigation Menu -->
68+    <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
69+        <div class="pt-2 pb-3 space-y-1">
70+            <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
71+                {{ __('Dashboard') }}
72+            </x-responsive-nav-link>
73+        </div>
74+
75+        <!-- Responsive Settings Options -->
76+        <div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
77+            <div class="px-4">
78+                <div class="font-medium text-base text-gray-800 dark:text-gray-200">{{ Auth::user()->name }}</div>
79+                <div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
80+            </div>
81+
82+            <div class="mt-3 space-y-1">
83+                <x-responsive-nav-link :href="route('profile.edit')">
84+                    {{ __('Profile') }}
85+                </x-responsive-nav-link>
86+
87+                <!-- Authentication -->
88+                <form method="POST" action="{{ route('logout') }}">
89+                    @csrf
90+
91+                    <x-responsive-nav-link :href="route('logout')"
92+                            onclick="event.preventDefault();
93+                                        this.closest('form').submit();">
94+                        {{ __('Log Out') }}
95+                    </x-responsive-nav-link>
96+                </form>
97+            </div>
98+        </div>
99+    </div>
100+</nav>
101

/resources/views/posts/create.blade.php

/resources/views/posts/create.blade.php
1 @extends('layout')
2 
3 @section('content')
4-<div class="container mt-5">
5     <h2>新規投稿</h2>
6     <form action="{{ route('posts.store') }}" method="POST">
7         @csrf
8     <div class="mt-3">
9         <a href="{{ route('posts.index') }}" class="btn btn-secondary">戻る</a>
10     </div>
11-</div>
12 @endsection
13

/resources/views/posts/edit.blade.php

/resources/views/posts/edit.blade.php
1 @extends('layout')
2 
3 @section('content')
4-<div class="container mt-5">
5     <h2>投稿編集</h2>
6     <form action="{{ route('posts.update', $post->id) }}" method="POST">
7         @csrf
8         <button type="submit" class="btn btn-success">更新</button>
9         <a href="{{ route('posts.show', $post->id) }}" class="btn btn-secondary">戻る</a>
10     </form>
11-</div>
12 @endsection
13

/resources/views/posts/index.blade.php

/resources/views/posts/index.blade.php
1 @extends('layout')
2 
3 @section('content')
4-<div class="container mt-5">
5-    <h2>掲示板</h2>
6+    <h2>掲示板一覧</h2>
7     <a href="{{ route('posts.create') }}" class="btn btn-primary mb-3">新規投稿</a>
8     @foreach ($posts as $post)
9         <div class="card mb-3">
10             </div>
11         </div>
12     @endforeach
13-</div>
14 @endsection
15

/resources/views/posts/show.blade.php

/resources/views/posts/show.blade.php
1 @extends('layout')
2 
3 @section('content')
4-<div class="container mt-5">
5     <h2>投稿詳細</h2>
6     <div class="card mb-3">
7         <div class="card-body">
8             <h3 class="card-title">{{ $post->title }}</h3>
9             <p class="card-text">{{ $post->content }}</p>
10             <a href="{{ route('posts.index') }}" class="btn btn-secondary">戻る</a>
11-            <a href="{{ route('posts.edit', $post->id ) }}" class="btn btn-warning">編集</a>
12-            <form action="{{ route('posts.destroy', $post->id) }}" method="POST" class="d-inline">
13-                @csrf
14-                @method('DELETE')
15-                <button type="submit" class="btn btn-danger">削除</button>
16-            </form>
17+            @if(Auth::check() && Auth::id() === $post->user_id)
18+                <a href="{{ route('posts.edit', $post->id ) }}" class="btn btn-warning">編集</a>
19+                <form action="{{ route('posts.destroy', $post->id) }}" method="POST" class="d-inline">
20+                    @csrf
21+                    @method('DELETE')
22+                    <button type="submit" class="btn btn-danger">削除</button>
23+                </form>
24+                @endif
25         </div>
26     </div>
27-</div>
28 @endsection
29

/resources/views/profile/edit.blade.php

/resources/views/profile/edit.blade.php
1+<x-app-layout>
2+    <x-slot name="header">
3+        <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
4+            {{ __('Profile') }}
5+        </h2>
6+    </x-slot>
7+
8+    <div class="py-12">
9+        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
10+            <div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
11+                <div class="max-w-xl">
12+                    @include('profile.partials.update-profile-information-form')
13+                </div>
14+            </div>
15+
16+            <div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
17+                <div class="max-w-xl">
18+                    @include('profile.partials.update-password-form')
19+                </div>
20+            </div>
21+
22+            <div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
23+                <div class="max-w-xl">
24+                    @include('profile.partials.delete-user-form')
25+                </div>
26+            </div>
27+        </div>
28+    </div>
29+</x-app-layout>
30

/resources/views/profile/partials/delete-user-form.blade.php

/resources/views/profile/partials/delete-user-form.blade.php
1+<section class="space-y-6">
2+    <header>
3+        <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
4+            {{ __('Delete Account') }}
5+        </h2>
6+
7+        <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
8+            {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
9+        </p>
10+    </header>
11+
12+    <x-danger-button
13+        x-data=""
14+        x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
15+    >{{ __('Delete Account') }}</x-danger-button>
16+
17+    <x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
18+        <form method="post" action="{{ route('profile.destroy') }}" class="p-6">
19+            @csrf
20+            @method('delete')
21+
22+            <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
23+                {{ __('Are you sure you want to delete your account?') }}
24+            </h2>
25+
26+            <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
27+                {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
28+            </p>
29+
30+            <div class="mt-6">
31+                <x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
32+
33+                <x-text-input
34+                    id="password"
35+                    name="password"
36+                    type="password"
37+                    class="mt-1 block w-3/4"
38+                    placeholder="{{ __('Password') }}"
39+                />
40+
41+                <x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
42+            </div>
43+
44+            <div class="mt-6 flex justify-end">
45+                <x-secondary-button x-on:click="$dispatch('close')">
46+                    {{ __('Cancel') }}
47+                </x-secondary-button>
48+
49+                <x-danger-button class="ms-3">
50+                    {{ __('Delete Account') }}
51+                </x-danger-button>
52+            </div>
53+        </form>
54+    </x-modal>
55+</section>
56

/resources/views/profile/partials/update-password-form.blade.php

/resources/views/profile/partials/update-password-form.blade.php
1+<section>
2+    <header>
3+        <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
4+            {{ __('Update Password') }}
5+        </h2>
6+
7+        <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
8+            {{ __('Ensure your account is using a long, random password to stay secure.') }}
9+        </p>
10+    </header>
11+
12+    <form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
13+        @csrf
14+        @method('put')
15+
16+        <div>
17+            <x-input-label for="update_password_current_password" :value="__('Current Password')" />
18+            <x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
19+            <x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
20+        </div>
21+
22+        <div>
23+            <x-input-label for="update_password_password" :value="__('New Password')" />
24+            <x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
25+            <x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
26+        </div>
27+
28+        <div>
29+            <x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
30+            <x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
31+            <x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
32+        </div>
33+
34+        <div class="flex items-center gap-4">
35+            <x-primary-button>{{ __('Save') }}</x-primary-button>
36+
37+            @if (session('status') === 'password-updated')
38+                <p
39+                    x-data="{ show: true }"
40+                    x-show="show"
41+                    x-transition
42+                    x-init="setTimeout(() => show = false, 2000)"
43+                    class="text-sm text-gray-600 dark:text-gray-400"
44+                >{{ __('Saved.') }}</p>
45+            @endif
46+        </div>
47+    </form>
48+</section>
49

/resources/views/profile/partials/update-profile-information-form.blade.php

/resources/views/profile/partials/update-profile-information-form.blade.php
1+<section>
2+    <header>
3+        <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
4+            {{ __('Profile Information') }}
5+        </h2>
6+
7+        <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
8+            {{ __("Update your account's profile information and email address.") }}
9+        </p>
10+    </header>
11+
12+    <form id="send-verification" method="post" action="{{ route('verification.send') }}">
13+        @csrf
14+    </form>
15+
16+    <form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
17+        @csrf
18+        @method('patch')
19+
20+        <div>
21+            <x-input-label for="name" :value="__('Name')" />
22+            <x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
23+            <x-input-error class="mt-2" :messages="$errors->get('name')" />
24+        </div>
25+
26+        <div>
27+            <x-input-label for="email" :value="__('Email')" />
28+            <x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="username" />
29+            <x-input-error class="mt-2" :messages="$errors->get('email')" />
30+
31+            @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
32+                <div>
33+                    <p class="text-sm mt-2 text-gray-800 dark:text-gray-200">
34+                        {{ __('Your email address is unverified.') }}
35+
36+                        <button form="send-verification" class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
37+                            {{ __('Click here to re-send the verification email.') }}
38+                        </button>
39+                    </p>
40+
41+                    @if (session('status') === 'verification-link-sent')
42+                        <p class="mt-2 font-medium text-sm text-green-600 dark:text-green-400">
43+                            {{ __('A new verification link has been sent to your email address.') }}
44+                        </p>
45+                    @endif
46+                </div>
47+            @endif
48+        </div>
49+
50+        <div class="flex items-center gap-4">
51+            <x-primary-button>{{ __('Save') }}</x-primary-button>
52+
53+            @if (session('status') === 'profile-updated')
54+                <p
55+                    x-data="{ show: true }"
56+                    x-show="show"
57+                    x-transition
58+                    x-init="setTimeout(() => show = false, 2000)"
59+                    class="text-sm text-gray-600 dark:text-gray-400"
60+                >{{ __('Saved.') }}</p>
61+            @endif
62+        </div>
63+    </form>
64+</section>
65

/routes/auth.php

/routes/auth.php
1+<?php
2+
3+use App\Http\Controllers\Auth\AuthenticatedSessionController;
4+use App\Http\Controllers\Auth\ConfirmablePasswordController;
5+use App\Http\Controllers\Auth\EmailVerificationNotificationController;
6+use App\Http\Controllers\Auth\EmailVerificationPromptController;
7+use App\Http\Controllers\Auth\NewPasswordController;
8+use App\Http\Controllers\Auth\PasswordController;
9+use App\Http\Controllers\Auth\PasswordResetLinkController;
10+use App\Http\Controllers\Auth\RegisteredUserController;
11+use App\Http\Controllers\Auth\VerifyEmailController;
12+use Illuminate\Support\Facades\Route;
13+
14+Route::middleware('guest')->group(function () {
15+    Route::get('register', [RegisteredUserController::class, 'create'])
16+        ->name('register');
17+
18+    Route::post('register', [RegisteredUserController::class, 'store']);
19+
20+    Route::get('login', [AuthenticatedSessionController::class, 'create'])
21+        ->name('login');
22+
23+    Route::post('login', [AuthenticatedSessionController::class, 'store']);
24+
25+    Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
26+        ->name('password.request');
27+
28+    Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
29+        ->name('password.email');
30+
31+    Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
32+        ->name('password.reset');
33+
34+    Route::post('reset-password', [NewPasswordController::class, 'store'])
35+        ->name('password.store');
36+});
37+
38+Route::middleware('auth')->group(function () {
39+    Route::get('verify-email', EmailVerificationPromptController::class)
40+        ->name('verification.notice');
41+
42+    Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
43+        ->middleware(['signed', 'throttle:6,1'])
44+        ->name('verification.verify');
45+
46+    Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
47+        ->middleware('throttle:6,1')
48+        ->name('verification.send');
49+
50+    Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
51+        ->name('password.confirm');
52+
53+    Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
54+
55+    Route::put('password', [PasswordController::class, 'update'])->name('password.update');
56+
57+    Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
58+        ->name('logout');
59+});
60

/routes/web.php

/routes/web.php
1 <?php
2 
3-use Illuminate\Support\Facades\Route;
4+use App\Http\Controllers\ProfileController;
5 use App\Http\Controllers\PostController;
6+use Illuminate\Support\Facades\Route;
7 
8 Route::get('/', function () {
9     return view('welcome');
10 });
11 
12-Route::resource('posts', PostController::class);
13+Route::get('/dashboard', function () {
14+    return view('dashboard');
15+})->middleware(['auth', 'verified'])->name('dashboard');
16+
17+Route::middleware('auth')->group(function () {
18+    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
19+    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
20+    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
21+});
22+
23+// ログイン必須の新規投稿と保存のルート設定
24+Route::middleware(['auth'])->group(function () {
25+    Route::get('/posts/create', [PostController::class, 'create'])->name('posts.create');
26+    Route::post('/posts', [PostController::class, 'store'])->name('posts.store');
27+});
28+
29+// 未ログインでもアクセス可能な一覧と詳細ページ
30+Route::resource('posts', PostController::class)->only(['index', 'show']);
31+
32+// 編集・削除はログインが必要だが、権限はポリシーで制限
33+Route::resource('posts', PostController::class)->except(['create', 'store', 'index', 'show'])->middleware('auth');
34+
35+require __DIR__.'/auth.php';
36

/tailwind.config.js

/tailwind.config.js
1 import defaultTheme from 'tailwindcss/defaultTheme';
2+import forms from '@tailwindcss/forms';
3 
4 /** @type {import('tailwindcss').Config} */
5 export default {
6     content: [
7         './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
8         './storage/framework/views/*.php',
9-        './resources/**/*.blade.php',
10-        './resources/**/*.js',
11-        './resources/**/*.vue',
12+        './resources/views/**/*.blade.php',
13     ],
14+
15     theme: {
16         extend: {
17             fontFamily: {
18             },
19         },
20     },
21-    plugins: [],
22+
23+    plugins: [forms],
24 };
25

/tests/Feature/Auth/AuthenticationTest.php

/tests/Feature/Auth/AuthenticationTest.php
1+<?php
2+
3+namespace Tests\Feature\Auth;
4+
5+use App\Models\User;
6+use Illuminate\Foundation\Testing\RefreshDatabase;
7+use Tests\TestCase;
8+
9+class AuthenticationTest extends TestCase
10+{
11+    use RefreshDatabase;
12+
13+    public function test_login_screen_can_be_rendered(): void
14+    {
15+        $response = $this->get('/login');
16+
17+        $response->assertStatus(200);
18+    }
19+
20+    public function test_users_can_authenticate_using_the_login_screen(): void
21+    {
22+        $user = User::factory()->create();
23+
24+        $response = $this->post('/login', [
25+            'email' => $user->email,
26+            'password' => 'password',
27+        ]);
28+
29+        $this->assertAuthenticated();
30+        $response->assertRedirect(route('dashboard', absolute: false));
31+    }
32+
33+    public function test_users_can_not_authenticate_with_invalid_password(): void
34+    {
35+        $user = User::factory()->create();
36+
37+        $this->post('/login', [
38+            'email' => $user->email,
39+            'password' => 'wrong-password',
40+        ]);
41+
42+        $this->assertGuest();
43+    }
44+
45+    public function test_users_can_logout(): void
46+    {
47+        $user = User::factory()->create();
48+
49+        $response = $this->actingAs($user)->post('/logout');
50+
51+        $this->assertGuest();
52+        $response->assertRedirect('/');
53+    }
54+}
55

/tests/Feature/Auth/EmailVerificationTest.php

/tests/Feature/Auth/EmailVerificationTest.php
1+<?php
2+
3+namespace Tests\Feature\Auth;
4+
5+use App\Models\User;
6+use Illuminate\Auth\Events\Verified;
7+use Illuminate\Foundation\Testing\RefreshDatabase;
8+use Illuminate\Support\Facades\Event;
9+use Illuminate\Support\Facades\URL;
10+use Tests\TestCase;
11+
12+class EmailVerificationTest extends TestCase
13+{
14+    use RefreshDatabase;
15+
16+    public function test_email_verification_screen_can_be_rendered(): void
17+    {
18+        $user = User::factory()->unverified()->create();
19+
20+        $response = $this->actingAs($user)->get('/verify-email');
21+
22+        $response->assertStatus(200);
23+    }
24+
25+    public function test_email_can_be_verified(): void
26+    {
27+        $user = User::factory()->unverified()->create();
28+
29+        Event::fake();
30+
31+        $verificationUrl = URL::temporarySignedRoute(
32+            'verification.verify',
33+            now()->addMinutes(60),
34+            ['id' => $user->id, 'hash' => sha1($user->email)]
35+        );
36+
37+        $response = $this->actingAs($user)->get($verificationUrl);
38+
39+        Event::assertDispatched(Verified::class);
40+        $this->assertTrue($user->fresh()->hasVerifiedEmail());
41+        $response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
42+    }
43+
44+    public function test_email_is_not_verified_with_invalid_hash(): void
45+    {
46+        $user = User::factory()->unverified()->create();
47+
48+        $verificationUrl = URL::temporarySignedRoute(
49+            'verification.verify',
50+            now()->addMinutes(60),
51+            ['id' => $user->id, 'hash' => sha1('wrong-email')]
52+        );
53+
54+        $this->actingAs($user)->get($verificationUrl);
55+
56+        $this->assertFalse($user->fresh()->hasVerifiedEmail());
57+    }
58+}
59

/tests/Feature/Auth/PasswordConfirmationTest.php

/tests/Feature/Auth/PasswordConfirmationTest.php
1+<?php
2+
3+namespace Tests\Feature\Auth;
4+
5+use App\Models\User;
6+use Illuminate\Foundation\Testing\RefreshDatabase;
7+use Tests\TestCase;
8+
9+class PasswordConfirmationTest extends TestCase
10+{
11+    use RefreshDatabase;
12+
13+    public function test_confirm_password_screen_can_be_rendered(): void
14+    {
15+        $user = User::factory()->create();
16+
17+        $response = $this->actingAs($user)->get('/confirm-password');
18+
19+        $response->assertStatus(200);
20+    }
21+
22+    public function test_password_can_be_confirmed(): void
23+    {
24+        $user = User::factory()->create();
25+
26+        $response = $this->actingAs($user)->post('/confirm-password', [
27+            'password' => 'password',
28+        ]);
29+
30+        $response->assertRedirect();
31+        $response->assertSessionHasNoErrors();
32+    }
33+
34+    public function test_password_is_not_confirmed_with_invalid_password(): void
35+    {
36+        $user = User::factory()->create();
37+
38+        $response = $this->actingAs($user)->post('/confirm-password', [
39+            'password' => 'wrong-password',
40+        ]);
41+
42+        $response->assertSessionHasErrors();
43+    }
44+}
45

/tests/Feature/Auth/PasswordResetTest.php

/tests/Feature/Auth/PasswordResetTest.php
1+<?php
2+
3+namespace Tests\Feature\Auth;
4+
5+use App\Models\User;
6+use Illuminate\Auth\Notifications\ResetPassword;
7+use Illuminate\Foundation\Testing\RefreshDatabase;
8+use Illuminate\Support\Facades\Notification;
9+use Tests\TestCase;
10+
11+class PasswordResetTest extends TestCase
12+{
13+    use RefreshDatabase;
14+
15+    public function test_reset_password_link_screen_can_be_rendered(): void
16+    {
17+        $response = $this->get('/forgot-password');
18+
19+        $response->assertStatus(200);
20+    }
21+
22+    public function test_reset_password_link_can_be_requested(): void
23+    {
24+        Notification::fake();
25+
26+        $user = User::factory()->create();
27+
28+        $this->post('/forgot-password', ['email' => $user->email]);
29+
30+        Notification::assertSentTo($user, ResetPassword::class);
31+    }
32+
33+    public function test_reset_password_screen_can_be_rendered(): void
34+    {
35+        Notification::fake();
36+
37+        $user = User::factory()->create();
38+
39+        $this->post('/forgot-password', ['email' => $user->email]);
40+
41+        Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
42+            $response = $this->get('/reset-password/'.$notification->token);
43+
44+            $response->assertStatus(200);
45+
46+            return true;
47+        });
48+    }
49+
50+    public function test_password_can_be_reset_with_valid_token(): void
51+    {
52+        Notification::fake();
53+
54+        $user = User::factory()->create();
55+
56+        $this->post('/forgot-password', ['email' => $user->email]);
57+
58+        Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
59+            $response = $this->post('/reset-password', [
60+                'token' => $notification->token,
61+                'email' => $user->email,
62+                'password' => 'password',
63+                'password_confirmation' => 'password',
64+            ]);
65+
66+            $response
67+                ->assertSessionHasNoErrors()
68+                ->assertRedirect(route('login'));
69+
70+            return true;
71+        });
72+    }
73+}
74

/tests/Feature/Auth/PasswordUpdateTest.php

/tests/Feature/Auth/PasswordUpdateTest.php
1+<?php
2+
3+namespace Tests\Feature\Auth;
4+
5+use App\Models\User;
6+use Illuminate\Foundation\Testing\RefreshDatabase;
7+use Illuminate\Support\Facades\Hash;
8+use Tests\TestCase;
9+
10+class PasswordUpdateTest extends TestCase
11+{
12+    use RefreshDatabase;
13+
14+    public function test_password_can_be_updated(): void
15+    {
16+        $user = User::factory()->create();
17+
18+        $response = $this
19+            ->actingAs($user)
20+            ->from('/profile')
21+            ->put('/password', [
22+                'current_password' => 'password',
23+                'password' => 'new-password',
24+                'password_confirmation' => 'new-password',
25+            ]);
26+
27+        $response
28+            ->assertSessionHasNoErrors()
29+            ->assertRedirect('/profile');
30+
31+        $this->assertTrue(Hash::check('new-password', $user->refresh()->password));
32+    }
33+
34+    public function test_correct_password_must_be_provided_to_update_password(): void
35+    {
36+        $user = User::factory()->create();
37+
38+        $response = $this
39+            ->actingAs($user)
40+            ->from('/profile')
41+            ->put('/password', [
42+                'current_password' => 'wrong-password',
43+                'password' => 'new-password',
44+                'password_confirmation' => 'new-password',
45+            ]);
46+
47+        $response
48+            ->assertSessionHasErrorsIn('updatePassword', 'current_password')
49+            ->assertRedirect('/profile');
50+    }
51+}
52

/tests/Feature/Auth/RegistrationTest.php

/tests/Feature/Auth/RegistrationTest.php
1+<?php
2+
3+namespace Tests\Feature\Auth;
4+
5+use Illuminate\Foundation\Testing\RefreshDatabase;
6+use Tests\TestCase;
7+
8+class RegistrationTest extends TestCase
9+{
10+    use RefreshDatabase;
11+
12+    public function test_registration_screen_can_be_rendered(): void
13+    {
14+        $response = $this->get('/register');
15+
16+        $response->assertStatus(200);
17+    }
18+
19+    public function test_new_users_can_register(): void
20+    {
21+        $response = $this->post('/register', [
22+            'name' => 'Test User',
23+            'email' => 'test@example.com',
24+            'password' => 'password',
25+            'password_confirmation' => 'password',
26+        ]);
27+
28+        $this->assertAuthenticated();
29+        $response->assertRedirect(route('dashboard', absolute: false));
30+    }
31+}
32

/tests/Feature/ProfileTest.php

/tests/Feature/ProfileTest.php
1+<?php
2+
3+namespace Tests\Feature;
4+
5+use App\Models\User;
6+use Illuminate\Foundation\Testing\RefreshDatabase;
7+use Tests\TestCase;
8+
9+class ProfileTest extends TestCase
10+{
11+    use RefreshDatabase;
12+
13+    public function test_profile_page_is_displayed(): void
14+    {
15+        $user = User::factory()->create();
16+
17+        $response = $this
18+            ->actingAs($user)
19+            ->get('/profile');
20+
21+        $response->assertOk();
22+    }
23+
24+    public function test_profile_information_can_be_updated(): void
25+    {
26+        $user = User::factory()->create();
27+
28+        $response = $this
29+            ->actingAs($user)
30+            ->patch('/profile', [
31+                'name' => 'Test User',
32+                'email' => 'test@example.com',
33+            ]);
34+
35+        $response
36+            ->assertSessionHasNoErrors()
37+            ->assertRedirect('/profile');
38+
39+        $user->refresh();
40+
41+        $this->assertSame('Test User', $user->name);
42+        $this->assertSame('test@example.com', $user->email);
43+        $this->assertNull($user->email_verified_at);
44+    }
45+
46+    public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
47+    {
48+        $user = User::factory()->create();
49+
50+        $response = $this
51+            ->actingAs($user)
52+            ->patch('/profile', [
53+                'name' => 'Test User',
54+                'email' => $user->email,
55+            ]);
56+
57+        $response
58+            ->assertSessionHasNoErrors()
59+            ->assertRedirect('/profile');
60+
61+        $this->assertNotNull($user->refresh()->email_verified_at);
62+    }
63+
64+    public function test_user_can_delete_their_account(): void
65+    {
66+        $user = User::factory()->create();
67+
68+        $response = $this
69+            ->actingAs($user)
70+            ->delete('/profile', [
71+                'password' => 'password',
72+            ]);
73+
74+        $response
75+            ->assertSessionHasNoErrors()
76+            ->assertRedirect('/');
77+
78+        $this->assertGuest();
79+        $this->assertNull($user->fresh());
80+    }
81+
82+    public function test_correct_password_must_be_provided_to_delete_account(): void
83+    {
84+        $user = User::factory()->create();
85+
86+        $response = $this
87+            ->actingAs($user)
88+            ->from('/profile')
89+            ->delete('/profile', [
90+                'password' => 'wrong-password',
91+            ]);
92+
93+        $response
94+            ->assertSessionHasErrorsIn('userDeletion', 'password')
95+            ->assertRedirect('/profile');
96+
97+        $this->assertNotNull($user->fresh());
98+    }
99+}
100

/vite.config.js

/vite.config.js
1 export default defineConfig({
2     plugins: [
3         laravel({
4-            input: ['resources/css/app.css', 'resources/js/app.js'],
5+            input: [
6+                'resources/css/app.css',
7+                'resources/js/app.js',
8+            ],
9             refresh: true,
10         }),
11     ],
12

コード解説

変更点: 認証パッケージLaravel Breezeの追加

/composer.json
1     },
2     "require-dev": {
3         "fakerphp/faker": "^1.23",
4+        "laravel/breeze": "^2.2",
5         "laravel/pail": "^1.1",
6         "laravel/pint": "^1.13",
7         "laravel/sail": "^1.26",
8

Laravelプロジェクトに、ログイン認証機能一式を簡単に追加できるパッケージ「Laravel Breeze」をインストールします。composer.jsonは、プロジェクトが利用するPHPパッケージを管理するファイルです。ここにlaravel/breezeを追記し、インストールすることで、認証機能に必要なサーバー側のプログラムが使えるようになります。

変更点: フロントエンド開発環境のセットアップ

/package.json
1     "devDependencies": {
2-        "autoprefixer": "^10.4.20",
3+        "@tailwindcss/forms": "^0.5.2",
4+        "alpinejs": "^3.4.2",
5+        "autoprefixer": "^10.4.2",
6         "axios": "^1.7.4",
7         "concurrently": "^9.0.1",
8         "laravel-vite-plugin": "^1.0",
9-        "postcss": "^8.4.47",
10-        "tailwindcss": "^3.4.13",
11+        "postcss": "^8.4.31",
12+        "tailwindcss": "^3.1.0",
13         "vite": "^5.0"
14     }
15 }

package.jsonは、JavaScript関連のパッケージを管理するファイルです。Laravel Breezeは、認証画面の見た目や動きを整えるために、@tailwindcss/forms(Tailwind CSS用のフォームスタイル)やalpinejs(シンプルなJavaScriptフレームワーク)といったライブラリを使用します。これらのライブラリをプロジェクトに追加しています。

変更点: Alpine.jsの初期化

/resources/js/app.js
1 import './bootstrap';
2+
3+import Alpine from 'alpinejs';
4+
5+window.Alpine = Alpine;
6+
7+Alpine.start();

Breezeが提供する認証画面などのドロップダウンメニューといった動的な部分を機能させるために、JavaScriptフレームワークのAlpine.jsを読み込んで初期化しています。これにより、Webページ上でAlpine.jsが使えるようになります。

変更点: Tailwind CSSの設定更新

/tailwind.config.js
1 import defaultTheme from 'tailwindcss/defaultTheme';
2+import forms from '@tailwindcss/forms';
3 
4 /** @type {import('tailwindcss').Config} */
5 export default {
6     content: [
7         './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
8         './storage/framework/views/*.php',
9-        './resources/**/*.blade.php',
10-        './resources/**/*.js',
11-        './resources/**/*.vue',
12+        './resources/views/**/*.blade.php',
13     ],
14+
15     theme: {
16         extend: {
17             fontFamily: {
18             },
19         },
20     },
21-    plugins: [],
22+
23+    plugins: [forms],
24 };

CSSフレームワークであるTailwind CSSの設定ファイルです。Breezeのフォーム部品にきれいなスタイルを適用するため、プラグインとして@tailwindcss/formsを読み込んでいます。また、contentのパスを更新し、Tailwind CSSが適用されるファイルを正しく指定しています。

変更点: 投稿(posts)テーブルとユーザー(users)テーブルの関連付け

/database/migrations/2024_10_26_120312_add_user_id_to_posts_table.php
1+<?php
2+
3+use Illuminate\Database\Migrations\Migration;
4+use Illuminate\Database\Schema\Blueprint;
5+use Illuminate\Support\Facades\Schema;
6+
7+return new class extends Migration
8+{
9+    /**
10+     * Run the migrations.
11+     */
12+    public function up(): void
13+    {
14+        Schema::table('posts', function (Blueprint $table) {
15+            $table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
16+        });
17+    }
18+
19+    /**
20+     * Reverse the migrations.
21+     */
22+    public function down(): void
23+    {
24+        Schema::table('posts', function (Blueprint $table) {
25+            $table->dropForeign(['user_id']);
26+            $table->dropColumn('user_id');
27+        });
28+    }
29+};

データベースの設計図であるマイグレーションファイルを追加し、postsテーブルにuser_idという新しいカラムを追加します。これは、「どのユーザーがその投稿をしたのか」を記録するための重要なカラムです。constrained()usersテーブルのidと関連付ける設定で、onDelete('cascade')はユーザーが退会などで削除された場合に、そのユーザーの投稿も一緒に削除する設定です。

変更点: Postモデルにuser_idを追記

/app/Models/Post.php
1 {
2     protected $fillable = [
3         'title',
4-        'content'
5+        'content',
6+        'user_id'
7     ];
8 }

Postモデルは、postsテーブルのデータを操作するためのプログラムです。$fillableプロパティにuser_idを追加することで、プログラムからuser_idを含んだ投稿データを一度に作成・更新できるようになります。これはセキュリティ対策の一環で、意図しないカラムが更新されるのを防ぎます。

変更点: 認証関連のルート設定を追加

/routes/web.php
1-use Illuminate\Support\Facades\Route;
2+use App\Http\Controllers\ProfileController;
3 use App\Http\Controllers\PostController;
4+use Illuminate\Support\Facades\Route;
5 
6-Route::resource('posts', PostController::class);
7+Route::get('/dashboard', function () {
8+    return view('dashboard');
9+})->middleware(['auth', 'verified'])->name('dashboard');
10+
11+Route::middleware('auth')->group(function () {
12+    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
13+    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
14+    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
15+});
16+
17+// ログイン必須の新規投稿と保存のルート設定
18+Route::middleware(['auth'])->group(function () {
19+    Route::get('/posts/create', [PostController::class, 'create'])->name('posts.create');
20+    Route::post('/posts', [PostController::class, 'store'])->name('posts.store');
21+});
22+
23+// 未ログインでもアクセス可能な一覧と詳細ページ
24+Route::resource('posts', PostController::class)->only(['index', 'show']);
25+
26+// 編集・削除はログインが必要だが、権限はポリシーで制限
27+Route::resource('posts', PostController::class)->except(['create', 'store', 'index', 'show'])->middleware('auth');
28+
29+require __DIR__.'/auth.php';

WebアプリケーションのURLとプログラムの処理を結びつけるルート設定ファイルです。Breezeによって、ログイン後のダッシュボードやプロフィール編集画面のルートが追加されました。 また、掲示板機能のルートを以下のように整理しています。

  • middleware('auth')を使い、投稿の作成・保存・編集・更新・削除はログインしているユーザーしかアクセスできないように制限しています。
  • 一方で、投稿の一覧表示(index)と詳細表示(show)は誰でも見られるように、制限をかけていません。
  • 最後にrequire __DIR__.'/auth.php';で、Breezeが生成した認証関連のルート設定(ログイン、ログアウト、ユーザー登録など)を読み込んでいます。

変更点: 認証関連のルーティングファイルを生成

/routes/auth.php
1+<?php
2+
3+use App\Http\Controllers\Auth\AuthenticatedSessionController;
4+// ... (use statements)
5+use Illuminate\Support\Facades\Route;
6+
7+Route::middleware('guest')->group(function () {
8+    // ログイン、ユーザー登録、パスワードリセットなど
9+});
10+
11+Route::middleware('auth')->group(function () {
12+    // メール認証、パスワード確認、ログアウトなど
13+});

Breezeをインストールすると、認証機能専用のルート設定ファイルauth.phpが作成されます。ここには、ログイン、ログアウト、ユーザー登録、パスワードリセットなど、認証に関するすべてのルートがまとめられています。guestミドルウェアは未ログインユーザーのみ、authミドルウェアはログイン済みユーザーのみがアクセスできるルートを定義しています。

変更点: 新規登録後のリダイレクト先を変更

/app/Http/Controllers/Auth/RegisteredUserController.php
1         Auth::login($user);
2 
3+        // 新規登録後にリダイレクトするURLを指定
4+        return redirect()->route('posts.index');
5+
6         // return redirect(route('dashboard', absolute: false));
7     }
8 }

ユーザーの新規登録処理を担当するコントローラです。デフォルトでは、新規登録が完了するとダッシュボード画面に遷移しますが、今回は掲示板アプリなので、投稿一覧ページ(posts.index)に遷移するようにリダイレクト先を変更しています。

変更点: ログイン後のリダイレクト先を変更

/app/Http/Controllers/Auth/AuthenticatedSessionController.php
1         $request->session()->regenerate();
2 
3+        // Login後のリダイレクト先をposts.indexに変更
4+        return redirect()->route('posts.index');
5+
6         // return redirect()->intended(route('dashboard', absolute: false));
7     }
8 }

ログイン処理を担当するコントローラです。こちらも新規登録と同様に、ログインが成功した後の画面遷移先を、デフォルトのダッシュボードから投稿一覧ページ(posts.index)に変更しています。これにより、ユーザーはログイン後すぐに掲示板を閲覧できます。

変更点: 投稿機能にアクセス制御を実装

/app/Http/Controllers/PostController.php
1 
2 use Illuminate\Http\Request;
3 use App\Models\Post;
4+use Illuminate\Support\Facades\Auth;
5 
6 class PostController extends Controller
7 {
8      */
9     public function create()
10     {
11+        // ログインユーザーのみが新規作成ページにアクセス可能
12+        if (!Auth::check()) {
13+            return redirect()->route('login');
14+        }
15+
16         return view('posts.create');
17     }
18 
19      */
20     public function store(Request $request)
21     {
22+        // ログインユーザーのみが投稿可能
23+        if (!Auth::check()) {
24+            return redirect()->route('login');
25+        }
26+
27         $request->validate([
28             'title' => 'required|max:255',
29             'content' => 'required'
30         ]);
31 
32-        $post = Post::create($request->all());
33+        $post = Post::create([
34+            'title' => $request->title,
35+            'content' => $request->content,
36+            'user_id' => auth()->id()
37+        ]);
38 
39         return redirect()->route('posts.show', ['post' => $post->id]);
40     }
41
42     public function edit(string $id)
43     {
44         $post = Post::findOrFail($id);
45+
46+        // 所有者のみ編集ページにアクセス可能
47+        if (Auth::id() !== $post->user_id) {
48+            return redirect()->route('posts.index');
49+        }
50+
51         return view('posts.edit', compact('post'));
52     }
53
54     public function update(Request $request, string $id)
55     {
56         $post = Post::findOrFail($id);
57 
58+        // 所有者のみ更新可能
59+        if (Auth::id() !== $post->user_id) {
60+            return redirect()->route('posts.index');
61+        }
62+
63         $post->update([
64             'title' => $request->title,
65             'content' => $request->content
66         ]);
67     }
68
69     public function destroy(string $id)
70     {
71         $post = Post::findOrFail($id);
72+
73+        // 所有者のみ削除可能
74+        if (Auth::id() !== $post->user_id) {
75+            return redirect()->route('posts.index');
76+        }
77+
78         $post->delete();
79+
80         return redirect()->route('posts.index');
81     }
82 }

投稿のCRUD(作成、読み取り、更新、削除)処理を行うコントローラに、認証に基づいたアクセス制御を追加しました。

  • 投稿作成(create, store): Auth::check()を使い、ログインしていないユーザーがアクセスしようとした場合は、ログインページにリダイレクトさせます。
  • 投稿保存(store): 投稿データをデータベースに保存する際、'user_id' => auth()->id()というコードで、現在ログインしているユーザーのIDをuser_idとして保存します。これにより、投稿と作成者を紐付けます。
  • 編集・更新・削除(edit, update, destroy): Auth::id() !== $post->user_idという条件式で、ログインしているユーザーのIDと、投稿のuser_idを比較します。もし一致しない場合(つまり、他人の投稿を操作しようとした場合)、投稿一覧ページにリダイレクトさせ、不正な操作を防ぎます。これはアプリケーションのセキュリティを保つ上で非常に重要な処理です。

変更点: 共通レイアウトにナビゲーションバーを追加

/resources/views/layout.blade.php
1+    <nav class="navbar navbar-expand-lg bg-primary">
2+        <div class="container">
3+            <a class="navbar-brand text-white" href="{{ route('posts.index') }}">掲示板アプリ</a>
4+            <div class="ml-auto">
5+                <ul class="navbar-nav">
6+                    @if (Auth::check())
7+                        <li class="nav-item">
8+                            <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
9+                                @csrf
10+                            </form>
11+                            <a class="nav-link text-white" href="#" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">ログアウト</a>
12+                        </li>
13+                    @else
14+                        <li class="nav-item">
15+                            <a class="nav-link text-white" href="{{ route('login') }}">ログイン</a>
16+                        </li>
17+                        <li class="nav-item">
18+                            <a class="nav-link text-white" href="{{ route('register') }}">新規登録</a>
19+                        </li>
20+                    @endif
21+                </ul>
22+            </div>
23+        </div>
24+    </nav>
25+    <div class="container mt-4">
26+        @yield('content')
27+    </div>

すべてのページで共通して使われるレイアウトファイルです。ページ上部にナビゲーションバーを追加しました。@if (Auth::check())というBladeディレクティブを使い、ユーザーのログイン状態に応じて表示を切り替えています。

  • ログインしている場合(Auth::check()がtrue): 「ログアウト」リンクを表示します。
  • ログインしていない場合(Auth::check()がfalse): 「ログイン」と「新規登録」のリンクを表示します。

変更点: 投稿詳細ページに本人限定の操作ボタンを表示

/resources/views/posts/show.blade.php
1             <p class="card-text">{{ $post->content }}</p>
2             <a href="{{ route('posts.index') }}" class="btn btn-secondary">戻る</a>
3-            <a href="{{ route('posts.edit', $post->id ) }}" class="btn btn-warning">編集</a>
4-            <form action="{{ route('posts.destroy', $post->id) }}" method="POST" class="d-inline">
5-                @csrf
6-                @method('DELETE')
7-                <button type="submit" class="btn btn-danger">削除</button>
8-            </form>
9+            @if(Auth::check() && Auth::id() === $post->user_id)
10+                <a href="{{ route('posts.edit', $post->id ) }}" class="btn btn-warning">編集</a>
11+                <form action="{{ route('posts.destroy', $post->id) }}" method="POST" class="d-inline">
12+                    @csrf
13+                    @method('DELETE')
14+                    <button type="submit" class="btn btn-danger">削除</button>
15+                </form>
16+            @endif
17         </div>
18     </div>
19-</div>

投稿の詳細ページです。ここでも@ifディレクティブを使い、Auth::check()(ログインしているか)とAuth::id() === $post->user_id(自分の投稿か)の両方の条件を満たす場合にのみ、「編集」ボタンと「削除」ボタンを表示するように変更しました。コントローラでのアクセス制御と合わせて、UI上でも本人以外のユーザーには操作ボタンを見せないようにすることで、より親切な作りになります。

変更点: Breezeによる認証関連の画面ファイル追加

/resources/views/auth/login.blade.php
1+<x-guest-layout>
2+    <!-- Session Status -->
3+    <x-auth-session-status class="mb-4" :status="session('status')" />
4+
5+    <form method="POST" action="{{ route('login') }}">
6+        @csrf
7+
8+        <!-- Email Address -->
9+        <div>
10+            <x-input-label for="email" :value="__('Email')" />
11+            <x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
12+            <x-input-error :messages="$errors->get('email')" class="mt-2" />
13+        </div>
14+
15+        <!-- ... (中略) ... -->
16+
17+            <x-primary-button class="ms-3">
18+                {{ __('Log in') }}
19+            </x-primary-button>
20+        </div>
21+    </form>
22+</x-guest-layout>

Breezeをインストールすると、ログイン、ユーザー登録、パスワードリセットなどの認証機能に必要な画面(ビューファイル)が一式、resources/views/authディレクトリ内に生成されます。この差分はログイン画面のコードです。x-guest-layoutx-text-inputのように、<x-...>という形式で書かれているのは、再利用可能なUI部品(Bladeコンポーネント)を呼び出している記述です。これにより、統一感のあるUIを効率的に作成できます。同様のファイルが多数追加されています。

おわりに

今回はLaravel Breezeを使い、掲示板アプリに本格的なログイン認証機能を実装しました。Breezeを導入するだけで、認証に必要なコントローラやビュー、ルート設定などが自動で生成される手軽さを体験できました。さらに、PostControllerにログインユーザーのID(Auth::id())と投稿のuser_idを比較する処理を加え、投稿者本人だけが編集・削除できるアクセス制御を実現しました。認証機能をただ追加するだけでなく、既存の機能と連携させてセキュリティを向上させる具体的な方法を学べたことが、今回の大きな収穫です。

関連コンテンツ

関連IT用語