1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(フロントエンド実装編⑱)~トースト作成~

1
Last updated at Posted at 2026-04-30

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その39)

0. 初めに

こんにちは!

Webアプリケーションの作り方をゼロから解説しているシリーズです。

フロントエンド編も残りあとわずかとなりました!

今回は、トーストを作成したいと思います。

なんだかお腹がすいてきますね。(笑)

1. 下準備

いきなりトーストを作成する前に下準備をします。

1.1 ルーティング整理

ルーティングが書かれているweb.phpですが、使われていないものが残っていたたりして可読性が低い状態です。

まずは、これを直しましょう。

ブランチ運用

例によって、developブランチを最新化させて、新しいブランチを切って作業をしようと思います。

ブランチ名は、refactor/routingとかにしましょう。

名前付きルーティングの単・複統一

例えば、以下をご覧ください。

\project-root\src\routes\web.php
Route::get('/universities/{university}/faculties', [FacultyController::class, 'index'])->name('faculties.index');
Route::get('/universities/{university}/history', [UniversityController::class, 'history'])->name('university.history');

今のweb.phpです。
このように単数形と複数形が混在しているので、すべて複数形に統一したいと思います。

  • routes/web.php
クリックでコードを見る
\project-root\src\routes\web.php
<?php

use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Auth\GoogleAuthController;
use App\Http\Controllers\BookmarkController;
use App\Http\Controllers\CommentController;
use App\Http\Controllers\DeletionRequestController;
use App\Http\Controllers\FacultyController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LabController;
use App\Http\Controllers\MyPageController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ReviewController;
use App\Http\Controllers\UniversityController;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

// URLを'/auth'に変更
Route::get('/auth', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

// ホームページ
Route::get('/', [HomeController::class, 'home'])->name('home');

// ソーシャルログイン
Route::get('/auth/google', [GoogleAuthController::class, 'redirect'])->name('auth.google');
Route::get('/auth/google/callback', [GoogleAuthController::class, 'callback'])->name('auth.google.callback');

Route::get('/faculties/{faculty}/labs', [LabController::class, 'index'])->name('labs.index');
Route::get('/labs/{lab}', [LabController::class, 'show'])->name('labs.show');
Route::get('/universities', [UniversityController::class, 'index'])->name('universities.index');
Route::get('/universities/{university}/faculties', [FacultyController::class, 'index'])->name('faculties.index');
Route::get('/universities/{university}/history', [UniversityController::class, 'history'])->name('universities.history');
Route::get('/faculties/{faculty}/history', [FacultyController::class, 'history'])->name('faculties.history');
Route::get('/labs/{lab}/history', [LabController::class, 'history'])->name('labs.history');
Route::get('/labs/{lab}/comments', [CommentController::class, 'index'])->name('comments.index');

Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // レビュー関連
    Route::get(('/labs/{lab}/reviews/create'), [ReviewController::class, 'create'])->name('reviews.create');
    Route::post(('/labs/{lab}/reviews'), [ReviewController::class, 'store'])->name('reviews.store');
    Route::get('/reviews/{review}/edit', [ReviewController::class, 'edit'])->name('reviews.edit');
    Route::put('/reviews/{review}', [ReviewController::class, 'update'])->name('reviews.update');
    Route::delete('/reviews/{review}', [ReviewController::class, 'destroy'])->name('reviews.destroy');

    // 大学関連
    Route::get('/universities/create', [UniversityController::class, 'create'])->name('universities.create');
    Route::post('/universities', [UniversityController::class, 'store'])->name('universities.store');
    Route::get('/universities/{university}/edit', [UniversityController::class, 'edit'])->name('universities.edit');
    Route::put('/universities/{university}', [UniversityController::class, 'update'])->name('universities.update');

    // 学部関連
    Route::get('/universities/{university}/faculties/create', [FacultyController::class, 'create'])->name('faculties.create');
    Route::post('/universities/{university}/faculties', [FacultyController::class, 'store'])->name('faculties.store');
    Route::get('/faculties/{faculty}/edit', [FacultyController::class, 'edit'])->name('faculties.edit');
    Route::put('/faculties/{faculty}', [FacultyController::class, 'update'])->name('faculties.update');
    
    // 研究室関連
    Route::get('/faculties/{faculty}/labs/create', [LabController::class, 'create'])->name('labs.create');
    Route::post('/faculties/{faculty}/labs', [LabController::class, 'store'])->name('labs.store');
    Route::get('/labs/{lab}/edit', [LabController::class, 'edit'])->name('labs.edit');
    Route::put('/labs/{lab}', [LabController::class, 'update'])->name('labs.update');

    // コメント関連
    Route::get('/labs/{lab}/comments/create', [CommentController::class, 'create'])->name('comments.create');
    Route::post('/labs/{lab}/comments', [CommentController::class, 'store'])->name('comments.store');
    Route::get('/comments/{comment}/edit', [CommentController::class, 'edit'])->name('comments.edit');
    Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
    Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');

    // ブックマーク関連
    Route::post('/bookmarks', [BookmarkController::class, 'store'])->name('bookmarks.store');
    Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy'])->name('bookmarks.destroy');

    // マイページ関連
    Route::get('/mypage', [MyPageController::class, 'showUser'])->name('mypage.index');
    Route::get('/mypage/edit', [MyPageController::class, 'editUser'])->name('mypage.edit');
    Route::put('/mypage', [MyPageController::class, 'updateUser'])->name('mypage.update');
    Route::delete('/mypage', [MyPageController::class, 'deleteUser'])->name('mypage.delete');
    Route::get('/mypage/bookmarks', [MyPageController::class, 'showBookmarks'])->name('mypage.bookmarks');
    Route::delete('/mypage/bookmarks/{bookmark}', [MyPageController::class, 'removeBookmark'])->name('mypage.bookmarks.remove');

    // 削除依頼関連
    Route::get('/deletion-requests/create/{type}/{id}', [DeletionRequestController::class, 'create'])->name('deletion_requests.create');
    Route::post('/deletion-requests', [DeletionRequestController::class, 'store'])->name('deletion_requests.store');

    // 通知関連
    Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
    Route::post('/notifications/mark-as-read', [NotificationController::class, 'markAsRead'])->name('notifications.markAsRead');

    // 管理者用ルート
    Route::prefix('admin')->name('admin.')->group(function () {
        Route::delete('/universities/{university}', [AdminController::class, 'destroyUniversity'])->name('universities.destroy');
        Route::delete('/faculties/{faculty}', [AdminController::class, 'destroyFaculty'])->name('faculties.destroy');
        Route::delete('/labs/{lab}', [AdminController::class, 'destroyLab'])->name('labs.destroy');
        Route::delete('/comments/{comment}', [AdminController::class, 'destroyComment'])->name('comments.destroy');
        Route::get('/deletion-requests', [DeletionRequestController::class, 'index'])->name('deletion_requests.index');
    });
});

require __DIR__.'/auth.php';

呼び出しているフロントエンドも修正しないといけませんね。

  • resources/js/Components/Comment/CommentListModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Comment\CommentListModal.jsx
      await axios.put(route('comments.update', editingCommentId), {
  • resources/js/Components/Faculty/CreateFacultyModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Faculty\CreateFacultyModal.jsx
    post(route('faculties.store', university.id), {
  • resources/js/Components/Faculty/EditFacultyModal.jsx
クリックでコードを見る
\wsl.localhost\Ubuntu\home\kanaraki\app-lab-review\project-root\src\resources\js\Components\Faculty\EditFacultyModal.jsx
    put(route('faculties.update', faculty.id), {
  • resources/js/Components/Lab/CreateLabModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Lab\CreateLabModal.jsx
    post(route('labs.store', faculty.id), {
  • resources/js/Components/Review/CreateReviewModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Review\CreateReviewModal.jsx
    post(route('reviews.store', lab.id), {
  • resources/js/Components/Lab/EditLabModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Lab\EditLabModal.jsx
    put(route('labs.update', lab.id), {
  • resources/js/Components/Review/EditReviewModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Review\EditReviewModal.jsx
   put(route('reviews.update', review.id), {
  • resources/js/Components/University/CreateUniversityModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\University\CreateUniversityModal.jsx
    post(route('universities.store'), {
  • resources/js/Components/University/EditUniversityModal.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\University\EditUniversityModal.jsx
    put(route('universities.update', university.id), {
  • resources/js/Pages/Faculty/Index.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\University\EditUniversityModal.jsx
    router.get(route('universities.history', { university: university.id }), { query });
  • resources/js/Pages/Lab/Index.jsx
クリックでコードを見る
\project-root\src\resources\js\Pages\Lab\Index.jsx
    router.get(route('faculties.history', { faculty: faculty.id }), { query });
  • resources/js/Pages/Lab/Show.jsx
クリックでコードを見る
\project-root\src\resources\js\Pages\Lab\Show.jsx
      router.delete(route('bookmarks.destroy', bookmarkId), {

ここまでできたらいったんコミットしておきましょう。

不要なルーティング削除

これまで作成・編集系はモーダルで行うように変更してきたので、それら用のページを表示するためのルーティングは必要なくなったので削除します。

削除後のweb.phpは以下のようになります。

routes/web.php

クリックでコードを見る
\project-root\src\routes\web.php
<?php

use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Auth\GoogleAuthController;
use App\Http\Controllers\BookmarkController;
use App\Http\Controllers\CommentController;
use App\Http\Controllers\DeletionRequestController;
use App\Http\Controllers\FacultyController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LabController;
use App\Http\Controllers\MyPageController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ReviewController;
use App\Http\Controllers\UniversityController;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

// URLを'/auth'に変更
Route::get('/auth', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

// ホームページ
Route::get('/', [HomeController::class, 'home'])->name('home');

// ソーシャルログイン
Route::get('/auth/google', [GoogleAuthController::class, 'redirect'])->name('auth.google');
Route::get('/auth/google/callback', [GoogleAuthController::class, 'callback'])->name('auth.google.callback');

Route::get('/faculties/{faculty}/labs', [LabController::class, 'index'])->name('labs.index');
Route::get('/labs/{lab}', [LabController::class, 'show'])->name('labs.show');
Route::get('/universities', [UniversityController::class, 'index'])->name('universities.index');
Route::get('/universities/{university}/faculties', [FacultyController::class, 'index'])->name('faculties.index');
Route::get('/universities/{university}/history', [UniversityController::class, 'history'])->name('universities.history');
Route::get('/faculties/{faculty}/history', [FacultyController::class, 'history'])->name('faculties.history');
Route::get('/labs/{lab}/history', [LabController::class, 'history'])->name('labs.history');
Route::get('/labs/{lab}/comments', [CommentController::class, 'index'])->name('comments.index');

Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // レビュー関連
    Route::post(('/labs/{lab}/reviews'), [ReviewController::class, 'store'])->name('reviews.store');
    Route::put('/reviews/{review}', [ReviewController::class, 'update'])->name('reviews.update');
    Route::delete('/reviews/{review}', [ReviewController::class, 'destroy'])->name('reviews.destroy');

    // 大学関連
    Route::post('/universities', [UniversityController::class, 'store'])->name('universities.store');
    Route::put('/universities/{university}', [UniversityController::class, 'update'])->name('universities.update');

    // 学部関連
    Route::post('/universities/{university}/faculties', [FacultyController::class, 'store'])->name('faculties.store');
    Route::put('/faculties/{faculty}', [FacultyController::class, 'update'])->name('faculties.update');
    
    // 研究室関連
    Route::post('/faculties/{faculty}/labs', [LabController::class, 'store'])->name('labs.store');
    Route::put('/labs/{lab}', [LabController::class, 'update'])->name('labs.update');

    // コメント関連
    Route::post('/labs/{lab}/comments', [CommentController::class, 'store'])->name('comments.store');
    Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
    Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');

    // ブックマーク関連
    Route::post('/bookmarks', [BookmarkController::class, 'store'])->name('bookmarks.store');
    Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy'])->name('bookmarks.destroy');

    // マイページ関連
    Route::get('/mypage', [MyPageController::class, 'showUser'])->name('mypage.index');
    Route::get('/mypage/edit', [MyPageController::class, 'editUser'])->name('mypage.edit');
    Route::put('/mypage', [MyPageController::class, 'updateUser'])->name('mypage.update');
    Route::delete('/mypage', [MyPageController::class, 'deleteUser'])->name('mypage.delete');
    Route::get('/mypage/bookmarks', [MyPageController::class, 'showBookmarks'])->name('mypage.bookmarks');
    Route::delete('/mypage/bookmarks/{bookmark}', [MyPageController::class, 'removeBookmark'])->name('mypage.bookmarks.remove');

    // 削除依頼関連
    Route::get('/deletion-requests/create/{type}/{id}', [DeletionRequestController::class, 'create'])->name('deletion_requests.create');
    Route::post('/deletion-requests', [DeletionRequestController::class, 'store'])->name('deletion_requests.store');

    // 通知関連
    Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
    Route::post('/notifications/mark-as-read', [NotificationController::class, 'markAsRead'])->name('notifications.markAsRead');

    // 管理者用ルート
    Route::prefix('admin')->name('admin.')->group(function () {
        Route::delete('/universities/{university}', [AdminController::class, 'destroyUniversity'])->name('universities.destroy');
        Route::delete('/faculties/{faculty}', [AdminController::class, 'destroyFaculty'])->name('faculties.destroy');
        Route::delete('/labs/{lab}', [AdminController::class, 'destroyLab'])->name('labs.destroy');
        Route::delete('/comments/{comment}', [AdminController::class, 'destroyComment'])->name('comments.destroy');
        Route::get('/deletion-requests', [DeletionRequestController::class, 'index'])->name('deletion_requests.index');
    });
});

require __DIR__.'/auth.php';

いったんコミットしておきます。

認証周り修正

これまでの実装で、ログインはページではなくモダールにするようにしました。

しかし、時々ログインページが表示されることがあったかと思います。

これは、サイドバーのログインメニューを押したときは問題ないのですが、セッションが切れてログアウトされて再ログインしようとするとログインページに遷移してしまうような処理がLaravelにデフォルトで備わっているからです。

このあたりの修正も含めて、ログイン周りのルーティングを整理していきます。

AuthenticatedSessionController

このコントローラーは、ログイン・ログアウトを処理するものです。

以下のようにします。
app/Http/Controllers/Auth/AuthenticatedSessionController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\Auth\AuthenticatedSessionController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthenticatedSessionController extends Controller
{

    /**
     * 受け取った認証リクエストを処理する
     */
    public function store(LoginRequest $request): RedirectResponse
    {
        $request->authenticate();

        $request->session()->regenerate();

        return redirect()->back()->with('success', 'ログインに成功しました。');
    }

    /**
     * 認証されたセッションを破棄する
     */
    public function destroy(Request $request): RedirectResponse
    {
        Auth::guard('web')->logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return redirect('/')->with('success', 'ログアウトに成功しました。');
    }
}

まず、もともとあったcreateメソッドは、ログインフォームのモーダル化に伴って不要になったので削除しました(必要だったuse宣言も消しています)。

次に、storeメソッドについてです。

public function store(LoginRequest $request): RedirectResponse
    {
        $request->authenticate();
        $request->session()->regenerate();
        return redirect()->back()->with('success', 'ログインに成功しました。');
    }

ログインのリクエストは、通常のRequestではなく、LoginRequestという特殊な型を使用します。

これには、バリデーション済みのデータが渡されるので、このコントローラー内でのバリデーションは不要です。

気になる方は、定義を見てみてください。

  • \app\Http\Requests\Auth\LoginRequest.php

authenticateメソッドは、このクラスのメソッドで、DBにあるIDとパスワードから認証をします。

次に、session()->regenerate()の部分です。
認証が成功したら、セッションにIDを作成して付ける処理です。

そもそもセッションとは?

セッションとは、簡単に言うとサーバー側に記録するユーザーを識別するためのメモ書きのようなものです。

これまでの開発で、リクエストという言葉を何度も使用してきましたが、これはクライアント(お客さんのこと。要はユーザーのPCやスマホなど)がサーバー(お店、つまりWebサイトを提供している会社の機械)に対して要求を送ることです。

例えば、「YouTubeで好きな動画を見せてください」とか「インスタに写真をアップロードさせてください」とかです。

それに対して、サーバーは「はい、これがゲーム実況動画です」とか「分かりました、あなたの写真を保存しました」とレスポンスを返すという仕組みです。

まだ、ローカルの開発環境で実装しているので、ピンとこないかもしれませんが、同じあなたのPC内でクライアントとサーバーの役割を二つ再現していると思ってもらえればよいです。

しかし、このリクエストとレスポンスをするとき以外は、お互い相手のことは意識していないため、もう一度再び通信をしようとすると、サーバーは新しくリクエストを送ってきた人なのか、それとも前にやり取りをしたことがある人なのかが区別できません。

これでは不便なので、セッションに識別子を付けて覚えておくことであげることで、「あ、この人は初見さんだな」とか「この人は常連さんやね」と分かって便利なわけです。

この仕組みは、ログイン・ログアウトに相性が良く、よく用いられています。

セッションについては、以下の記事が結構分かりやすくまとめられているかなと思いましたので、載せておきますね。
https://zenn.dev/collabostyle/articles/8949e8db686263

以下の部分は、セッションタイムアウト後の処理を修正したものです。

return redirect()->intended(url()->previous());

セッションは、有効時間が長すぎるとリスクが高くなる(理由は割愛しますが)ので、時間制限を設けています。

Laravelでは、以下のファイルに設定が書き込まれています。

  • \config\session.php
'lifetime' => env('SESSION_LIFETIME', 120),

デフォルトだと2時間ですね。

要は、これを過ぎるとログアウトされます。

そのため、2時間後に例えばマイページのようなログインユーザーしか開けないページを見ようとすると失敗します。

その場合、ログインページに遷移しますが、これは期待される動作ではありません。

なぜならログインもページ遷移ではなく、モーダルで行うようにしたからです。

よって、ログインページへの自動遷移の修正とログインページコンポーネントの削除をする必要があります(モーダルを作ったときに、忘れていました)。

  • 修正: bootstrap/app.php
クリックでコードを見る
\project-root\src\bootstrap\app.php
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->web(append: [
            \App\Http\Middleware\HandleInertiaRequests::class,
            \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
        ]);

        // 未認証ユーザーはホームページにリダイレクト(モーダルでログイン)
        $middleware->redirectGuestsTo('/');
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

なぜ今まで何も書かれていなかったのかと言いますと、Laravelのデフォルトで'/login'になるように設定されていたかららしいです。

authミドルウェアが未認証のユーザーを検出すると、そのユーザーをlogin名前付きルートへリダイレクトします。アプリケーションのbootstrap/app.phpファイル中の、redirectGuestsToメソッドを使用して、この動作を変更できます。

  • 削除 resources/js/Pages/Auth/Login.jsx

話がそれましたが、コントローラーの修正としては、気が付いたと思いますが、withメソッドを追加しています。

これは後で解説します。

また、destroy()メソッドはセッションの破棄を処理ます。

ConfirmablePasswordController

後は、パスワード変更等で遷移する先にもdashboardが設定されており、今回はパスワード変更処理は実装していませんが、意図しないページ遷移が起きる可能性があるので修正します。

それは、このパスワードを確認するコントローラーに設定されていた遷移先が'dashboard'だったからです。

'home'に直しておきましょう。

  • app/Http/Controllers/Auth/ConfirmablePasswordController.php
クリックでコードを見る
\project-root\src\app\Http\Controllers\MyPageController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;

class ConfirmablePasswordController extends Controller
{
    /**
     * Show the confirm password view.
     */
    public function show(): Response
    {
        return Inertia::render('Auth/ConfirmPassword');
    }

    /**
     * Confirm the user's password.
     */
    public function store(Request $request): RedirectResponse
    {
        if (! Auth::guard('web')->validate([
            'email' => $request->user()->email,
            'password' => $request->password,
        ])) {
            throw ValidationException::withMessages([
                'password' => __('auth.password'),
            ]);
        }

        $request->session()->put('auth.password_confirmed_at', time());

        return redirect()->intended(route('home', absolute: false));
    }
}

その他の認証系のコントローラーも修正です。

あと、ダッシュボードのページコンポーネントを削除しておきましょう。

  • 修正: app/Http/Controllers/Auth/EmailVerificationNotificationController.php
クリックでコードを見る
\project-root\src\app\Http\Controllers\Auth\EmailVerificationNotificationController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class EmailVerificationNotificationController extends Controller
{
    /**
     * Send a new email verification notification.
     */
    public function store(Request $request): RedirectResponse
    {
        if ($request->user()->hasVerifiedEmail()) {
            return redirect()->intended(route('home', absolute: false));
        }

        $request->user()->sendEmailVerificationNotification();

        return back()->with('status', 'verification-link-sent');
    }
}

  • 修正: app/Http/Controllers/Auth/EmailVerificationPromptController.php
クリックでコードを見る
\project-root\src\app\Http\Controllers\Auth\EmailVerificationPromptController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class EmailVerificationPromptController extends Controller
{
    /**
     * Display the email verification prompt.
     */
    public function __invoke(Request $request): RedirectResponse|Response
    {
        return $request->user()->hasVerifiedEmail()
                    ? redirect()->intended(route('home', absolute: false))
                    : Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
    }
}

  • 修正: app/Http/Controllers/Auth/GoogleAuthController.php
クリックでコードを見る
\project-root\src\app\Http\Controllers\Auth\GoogleAuthController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Support\Str;
use App\Models\User;
use Laravel\Socialite\Facades\Socialite;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\RedirectResponse;

class GoogleAuthController extends Controller
{
    public function redirect(): RedirectResponse
    {
        return Socialite::driver('google')->redirect();
    }

    public function callback(): RedirectResponse
    {
        try {
            $g = Socialite::driver('google')->user();
        } catch (\Throwable $e) {
            return redirect()->route('home')->with('status', 'Google認証に失敗しました。もう一度お試しください。');
        }

        // メール一致で既存ユーザーに紐付け(重複防止)
        $user = User::updateOrCreate(
            ['email' => $g->getEmail()],
            [
                'name' => $g->getName() ?: $g->getNickname() ?: 'Google User',
                'google_id' => $g->getId(),
                // 非NULL制約ならダミーPWを入れる(使われない)
                'password' => Hash::make(Str::random(40)),
                'email_verified_at' => now(),
            ]
        );

        Auth::login($user, remember: true);

        return redirect()->intended(route('home'))->with('success', 'Googleアカウントでログインしました。');
    }
}

↑これに関しては、'labs.home'をやめて'home'に変えたときの修正が反映されていなかったので直しました。

ついでに戻り値のタイプ宣言も追加してあります。

  • 修正: app/Http/Controllers/Auth/VerifyEmailController.php
クリックでコードを見る
\project-root\src\app\Http\Controllers\Auth\VerifyEmailController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;

class VerifyEmailController extends Controller
{
    /**
     * Mark the authenticated user's email address as verified.
     */
    public function __invoke(EmailVerificationRequest $request): RedirectResponse
    {
        if ($request->user()->hasVerifiedEmail()) {
            return redirect()->intended(route('home', absolute: false).'?verified=1');
        }

        if ($request->user()->markEmailAsVerified()) {
            event(new Verified($request->user()));
        }

        return redirect()->intended(route('home', absolute: false).'?verified=1');
    }
}

  • 削除: resources/js/Pages/Dashboard.jsx
app/Http/Controllers/Auth/RegisteredUserController.php

ログインを対応したので、当然新規登録の方も対応していきましょう。

クリックでコードを見る
\project-root\src\app\Http\Controllers\Auth\RegisteredUserController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;

class RegisteredUserController extends Controller
{
    /**
     * 新規登録リクエストを処理する
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'nickname' => 'required|string|max:255',
            'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
        ]);

        $user = User::create([
            'name' => $request->nickname,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        event(new Registered($user));

        Auth::login($user);

        return redirect()->back()->with('success', '登録に成功しました。');
    }
}

新規登録用のページコンポーネントも削除してきましょう。

  • 削除: resources/js/Pages/Auth/Register.jsx
app/Http/Controllers/Admin/AdminController.php

マイページコントローラー内に一部labs.homeが残っていたので直します。

クリックでコードを見る
\project-root\src\app\Http\Controllers\MyPageController.php
    public function deleteUser()
    {
        /** @var User $user */
        $user = Auth::user();
        $user->delete();

        return redirect()->route('home')->with('success', 'アカウントを削除しました');
    }
app/Http/Controllers/Admin/AdminController.php

管理者コントローラーも同様です。

また、リダイレクト先も修正しています。

クリックでコードを見る
\project-root\src\app\Http\Controllers\Admin\AdminController.php
<?php

namespace App\Http\Controllers\Admin; // 名前空間が他のコントローラーと異なり、整理されている

use App\Http\Controllers\Controller;
use App\Models\Comment;
use App\Models\DeletionRequest;
use App\Models\Faculty;
use App\Models\Lab;
use App\Models\University;
use App\Notifications\DeletionCompletedNotification;
use App\Notifications\ModelChangedNotification;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;

class AdminController extends Controller
{
    use AuthorizesRequests;
    
    public function destroyUniversity(University $university): RedirectResponse
    {
        // 認可チェック
        $this->authorize('delete', $university);

        $creator = $university->creator; // 作成者を取得
        $universityName = $university->name; // 通知用に大学名を取得

        // 対象の削除依頼があれば取得
        $deletionRequest = DeletionRequest::where('target_type', University::class)
            ->where('target_id', $university->id)
            ->first();

        // 通知送信
        if ($deletionRequest) {
            $requester = $deletionRequest->requester;
            $requester->notify(new DeletionCompletedNotification($university->name, '大学'));

            $deletionRequest->processed_by = auth()->id();
            $deletionRequest->status = 'approved';
            $deletionRequest->save();
        }

        $university->delete();

        // 通知送信(作成者へ)
        if ($creator && auth()->id() !== $creator->id) {
            $creator->notify(new ModelChangedNotification(
                'deleted',
                '大学',
                $universityName,
                $university->id
            ));
        }
        
        return redirect()->route('home')->with('success', '大学が削除されました。');
    }

    public function destroyFaculty(Faculty $faculty): RedirectResponse
    {
        // 認可チェック
        $this->authorize('delete', $faculty);

        $creator = $faculty->creator; // 作成者を取得
        $facultyName = $faculty->name; // 通知用に学部名を取得

        // 対象の削除依頼があれば取得
        $deletionRequest = DeletionRequest::where('target_type', Faculty::class)
            ->where('target_id', $faculty->id)
            ->first();

        // 通知送信
        if ($deletionRequest) {
            $requester = $deletionRequest->requester;
            $requester->notify(new DeletionCompletedNotification($faculty->name, '学部'));

            $deletionRequest->processed_by = auth()->id();
            $deletionRequest->status = 'approved';
            $deletionRequest->save();
        }

        $faculty->delete();

        // 通知送信(作成者へ)
        if ($creator && auth()->id() !== $creator->id) {
            $creator->notify(new ModelChangedNotification(
                'deleted',
                '学部',
                $facultyName,
                $faculty->id
            ));
        }

        return redirect()->route('faculties.index', ['university' => $faculty->university_id])->with('success', '学部が削除されました。');
    }

    public function destroyLab(Lab $lab)
    {
        // 認可チェック
        $this->authorize('delete', $lab);

        $creator = $lab->creator; // 作成者を取得
        $labName = $lab->name; // 通知用に研究室名を取得

        // 対象の削除依頼があれば取得
        $deletionRequest = DeletionRequest::where('target_type', Lab::class)
            ->where('target_id', $lab->id)
            ->first();

        // 通知送信(削除依頼者へ)
        if ($deletionRequest) {
            $requester = $deletionRequest->requester;
            $requester->notify(new DeletionCompletedNotification($lab->name, '研究室'));

            $deletionRequest->processed_by = auth()->id();
            $deletionRequest->status = 'approved';
            $deletionRequest->save();
        }

        $lab->delete();

        // 通知送信(作成者へ)
        if ($creator && auth()->id() !== $creator->id) {
            $creator->notify(new ModelChangedNotification(
                'deleted',
                '研究室',
                $labName,
                $lab->id
            ));
        }

        return redirect()->route('labs.index', ['faculty' => $lab->faculty_id])->with('success', '研究室が削除されました。');
    }

    public function destroyComment(Comment $comment)
    {
        // 認可チェック
        $this->authorize('delete', $comment);

        $comment->delete();
        return redirect()->route('labs.show', ['lab' => $comment->lab_id])->with('success', 'コメントが削除されました。');
    }
}

bootstrap/app.php

通常のルーティングはweb.phpに書いてきましたが、認証に関係するルーティングはこのファイルに書き込みます。

クリックでコードを見る
\project-root\src\routes\auth.php
Route::middleware('guest')->group(function () {
    // ログイン・新規登録はモーダルで行うため、GETはホームへリダイレクト
    Route::get('register', fn () => redirect('/'))
        ->name('register');

    Route::post('register', [RegisteredUserController::class, 'store']);

    Route::get('login', fn () => redirect('/'))
        ->name('login');

    Route::post('login', [AuthenticatedSessionController::class, 'store']);

    // ...
resources/js/Pages/Welcome.jsx

Laravelのデフォルトの初期画面です。

もはや必要ないので削除します。

あとは、web.php内で暫定的に残しておいた部分もそれに伴って削除してください。

\project-root\src\routes\web.php
// URLを'/auth'に変更
Route::get('/auth', function () {
    return Inertia::render('Welcome', [
        'canLogin' => Route::has('login'),
        'canRegister' => Route::has('register'),
        'laravelVersion' => Application::VERSION,
        'phpVersion' => PHP_VERSION,
    ]);
});

現段階のweb.phpは以下の通りです!
だいぶすっきりしたのではないでしょうか。

routes/web.php

クリックでコードを見る
\project-root\src\routes\web.php
<?php

use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Auth\GoogleAuthController;
use App\Http\Controllers\BookmarkController;
use App\Http\Controllers\CommentController;
use App\Http\Controllers\DeletionRequestController;
use App\Http\Controllers\FacultyController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LabController;
use App\Http\Controllers\MyPageController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ReviewController;
use App\Http\Controllers\UniversityController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;


// ホームページ
Route::get('/', [HomeController::class, 'home'])->name('home');

// ソーシャルログイン
Route::get('/auth/google', [GoogleAuthController::class, 'redirect'])->name('auth.google');
Route::get('/auth/google/callback', [GoogleAuthController::class, 'callback'])->name('auth.google.callback');

Route::get('/faculties/{faculty}/labs', [LabController::class, 'index'])->name('labs.index');
Route::get('/labs/{lab}', [LabController::class, 'show'])->name('labs.show');
Route::get('/universities', [UniversityController::class, 'index'])->name('universities.index');
Route::get('/universities/{university}/faculties', [FacultyController::class, 'index'])->name('faculties.index');
Route::get('/universities/{university}/history', [UniversityController::class, 'history'])->name('universities.history');
Route::get('/faculties/{faculty}/history', [FacultyController::class, 'history'])->name('faculties.history');
Route::get('/labs/{lab}/history', [LabController::class, 'history'])->name('labs.history');
Route::get('/labs/{lab}/comments', [CommentController::class, 'index'])->name('comments.index');

Route::get('/dashboard', function () {
    return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // レビュー関連
    Route::post(('/labs/{lab}/reviews'), [ReviewController::class, 'store'])->name('reviews.store');
    Route::put('/reviews/{review}', [ReviewController::class, 'update'])->name('reviews.update');
    Route::delete('/reviews/{review}', [ReviewController::class, 'destroy'])->name('reviews.destroy');

    // 大学関連
    Route::post('/universities', [UniversityController::class, 'store'])->name('universities.store');
    Route::put('/universities/{university}', [UniversityController::class, 'update'])->name('universities.update');

    // 学部関連
    Route::post('/universities/{university}/faculties', [FacultyController::class, 'store'])->name('faculties.store');
    Route::put('/faculties/{faculty}', [FacultyController::class, 'update'])->name('faculties.update');
    
    // 研究室関連
    Route::post('/faculties/{faculty}/labs', [LabController::class, 'store'])->name('labs.store');
    Route::put('/labs/{lab}', [LabController::class, 'update'])->name('labs.update');

    // コメント関連
    Route::post('/labs/{lab}/comments', [CommentController::class, 'store'])->name('comments.store');
    Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
    Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');

    // ブックマーク関連
    Route::post('/bookmarks', [BookmarkController::class, 'store'])->name('bookmarks.store');
    Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy'])->name('bookmarks.destroy');

    // マイページ関連
    Route::get('/mypage', [MyPageController::class, 'showUser'])->name('mypage.index');
    Route::get('/mypage/edit', [MyPageController::class, 'editUser'])->name('mypage.edit');
    Route::put('/mypage', [MyPageController::class, 'updateUser'])->name('mypage.update');
    Route::delete('/mypage', [MyPageController::class, 'deleteUser'])->name('mypage.delete');
    Route::get('/mypage/bookmarks', [MyPageController::class, 'showBookmarks'])->name('mypage.bookmarks');
    Route::delete('/mypage/bookmarks/{bookmark}', [MyPageController::class, 'removeBookmark'])->name('mypage.bookmarks.remove');

    // 削除依頼関連
    Route::get('/deletion-requests/create/{type}/{id}', [DeletionRequestController::class, 'create'])->name('deletion_requests.create');
    Route::post('/deletion-requests', [DeletionRequestController::class, 'store'])->name('deletion_requests.store');

    // 通知関連
    Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
    Route::post('/notifications/mark-as-read', [NotificationController::class, 'markAsRead'])->name('notifications.markAsRead');

    // 管理者用ルート
    Route::prefix('admin')->name('admin.')->group(function () {
        Route::delete('/universities/{university}', [AdminController::class, 'destroyUniversity'])->name('universities.destroy');
        Route::delete('/faculties/{faculty}', [AdminController::class, 'destroyFaculty'])->name('faculties.destroy');
        Route::delete('/labs/{lab}', [AdminController::class, 'destroyLab'])->name('labs.destroy');
        Route::delete('/comments/{comment}', [AdminController::class, 'destroyComment'])->name('comments.destroy');
        Route::get('/deletion-requests', [DeletionRequestController::class, 'index'])->name('deletion_requests.index');
    });
});

require __DIR__.'/auth.php';

ここまでできたら、コミット・プッシュ、PR作成・マージをしておきましょう。

1.2 例外処理整理

続いて、例外処理を整理します。

ブランチ運用

developブランチを最新化させて新規ランチを切って作業しましょう。

ブランチ名は、refactor/try-catchとかでいきます。

app/Http/Controllers/Auth/GoogleAuthController.php

ソーシャルログインの処理を担当しているコントローラーですが、2点問題がありました。

  • catchブロック内でエラーをそのままにしていた
  • エラー時のフラッシュキーが間違っていた('status')

よって以下のように、修正してください。

クリックでコードを見る
\project-root\src\app\Http\Controllers\Auth\GoogleAuthController.php
use Illuminate\Support\Facades\Log; // 追加

class GoogleAuthController extends Controller
{
    // redirect()の定義

    public function callback(): RedirectResponse
    {
        try {
            $g = Socialite::driver('google')->user();
        } catch (\Throwable $e) {
            Log::error('Google認証エラー: ' . $e->getMessage()); // ログ出力をするように修正
            return redirect()->route('home')->with('error', 'Google認証に失敗しました。もう一度お試しください。'); // 'status' => 'error'
        }

        // メール一致で既存ユーザーに紐付け(重複防止)
        $user = User::updateOrCreate(
            ['email' => $g->getEmail()],
            [
                'name' => $g->getName() ?: $g->getNickname() ?: 'Google User',
                'google_id' => $g->getId(),
                // 非NULL制約ならダミーPWを入れる(使われない)
                'password' => Hash::make(Str::random(40)),
                'email_verified_at' => now(),
            ]
        );

        Auth::login($user, remember: true);

        return redirect()->intended(route('home'))->with('success', 'Googleアカウントでログインしました。');
    }
}

app/Http/Controllers/UniversityController.php

大学コントローラーのupdateメソッドにも問題があったので、あったので直します。

  • DBコミット後に通知を送信しているため通知で失敗するとロールバックが利かない
  • 変更前の値を取得できていない

app/Http/Controllers/UniversityController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\UniversityController.php
    public function update(Request $request, University $university)
    {
        $this->authorize('update', University::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name,' . $university->id,
            'type' => 'required|string|in:national,public,private',
            'comment' => 'required|string|max:255',
            'version' => 'required|integer',
        ]);

        // 変更前の値を保持(通知用)
        $oldValues = $university->only(['name']);

        // トランザクション開始
        DB::beginTransaction();

        try {
            // 他のユーザーが更新している可能性がある
            // そのため、最初に最新の university を取得
            $current = University::find($university->id);

            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの大学情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // データ更新
            $current->name = $validated['name'];
            $current->type = $validated['type'];
            $current->version += 1; // バージョンを1増やす
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $current->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit();
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }

        // 作成者へ通知を送信(トランザクション外へ移動)
        try {
            if ($userId !== $current->created_by && $current->creator) {
                $changes = collect($current->getChanges())
                    ->only(['name'])
                    ->map(fn($new, $key) => [
                        'old' => $oldValues[$key] ?? null,
                        'new' => $new,
                    ])
                    ->toArray();

                $current->creator->notify(
                    new ModelChangedNotification('edited', '大学', $current->name, $current->id, $changes)
                );
            }
        } catch (\Exception $e) {
            Log::error('大学更新通知の送信に失敗', ['university_id' => $current->id, 'message' => $e->getMessage()]);
        }

        return redirect()->route('faculties.index', ['university' => $current])->with('success', '大学情報が更新されました。');
    }

app/Http/Controllers/FacultyController.php

学部コントローラーも大学と同じノリで直します。

クリックでコードを見る
\project-root\src\app\Http\Controllers\FacultyController.php
    public function update(Request $request, Faculty $faculty)
    {
        $this->authorize('update', Faculty::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name,' . $faculty->id,
            'comment' => 'required|string|max:255',
            'version' => 'required|integer',
        ]);

        // 変更前の値を保持(通知用)
        $oldValues = $faculty->only(['name']);

        // トランザクション
        DB::beginTransaction();

        try {
            // 現在のバージョンを取得して比較
            $current = Faculty::find($faculty->id);
            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの学部情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // 更新
            $current->name = $validated['name'];
            $current->version += 1;
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $current->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit();
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }

        // 作成者へ通知を送信(トランザクション外)
        try {
            if ($userId !== $current->created_by && $current->creator) {
                $changes = collect($current->getChanges())
                    ->only(['name'])
                    ->map(fn($new, $key) => [
                        'old' => $oldValues[$key] ?? null,
                        'new' => $new,
                    ])
                    ->toArray();

                $current->creator->notify(
                    new ModelChangedNotification('edited', '学部', $current->name, $current->id, $changes)
                );
            }
        } catch (\Exception $e) {
            Log::error('学部更新通知の送信に失敗', ['faculty_id' => $current->id, 'message' => $e->getMessage()]);
        }

        return redirect()->route('labs.index', ['faculty' => $current])->with('success', '学部情報が更新されました。');
    }

app/Http/Controllers/LabController.php

研究室コントローラーも直します。

クリックでコードを見る
\project-root\src\app\Http\Controllers\LabController.php
    public function update(Request $request, Lab $lab)
    {
        $this->authorize('update', Lab::class);

        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:labs,name,' . $lab->id . ',id,faculty_id,' . $lab->faculty_id,
            'description' => 'nullable|string|max:150',
            'url' => 'nullable|url|max:255',
            'professor_name' => 'nullable|string|max:25',
            'professor_url' => 'nullable|url|max:255',
            'gender_ratio_male' => 'required|integer|min:0|max:10',
            'gender_ratio_female' => [
                'required',
                'integer',
                'min:0',
                'max:10',
                function ($attribute, $value, $fail) use ($request) {
                    $male = (int) $request->input('gender_ratio_male', 0);
                    $female = (int) $value;
                    if ($male + $female !== 10) {
                        $fail('男女比の合計は10である必要があります。');
                    }
                },
            ],
            'comment' => 'required|string|max:255',
            'version' => 'required|integer',
        ]);

        // 変更前の値を保持(通知用)
        $oldValues = $lab->only(['name']);

        DB::beginTransaction();

        try {
            // 現在のバージョンを取得して比較
            $current = Lab::find($lab->id);
            if ($validated['version'] !== $current->version) {
                throw ValidationException::withMessages([
                    'version' => '他のユーザーがこの研究室情報を更新しました。最新の情報を確認してください。',
                ]);
            }

            // 更新
            $current->name = $validated['name'];
            $current->description = $validated['description'];
            $current->url = $validated['url'];
            $current->professor_name = $validated['professor_name'];
            $current->professor_url = $validated['professor_url'];
            $current->gender_ratio_male = $validated['gender_ratio_male'];
            $current->gender_ratio_female = $validated['gender_ratio_female'];
            $current->version += 1;
            $current->save();

            // 履歴保存
            $userId = $request->user()->id;
            $lab->users()->attach($userId, [
                'comment' => $validated['comment'],
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::commit();
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }

        // 作成者へ通知を送信(トランザクション外)
        try {
            if ($userId !== $current->created_by && $current->creator) {
                $changes = collect($current->getChanges())
                    ->only(['name'])
                    ->map(fn($new, $key) => [
                        'old' => $oldValues[$key] ?? null,
                        'new' => $new,
                    ])
                    ->toArray();

                $current->creator->notify(
                    new ModelChangedNotification('edited', '研究室', $current->name, $current->id, $changes)
                );
            }
        } catch (\Exception $e) {
            Log::error('研究室更新通知の送信に失敗', ['lab_id' => $current->id, 'message' => $e->getMessage()]);
        }

        return redirect()->route('labs.show', ['lab' => $lab])->with('success', '研究室が更新されました。');
    }

ここまでできたら、コミット・プッシュ、PR作成・マージをしておきましょう。。

2. トースト作成

ようやく準備ができた(多分)ので、ここからトーストを表示できるようにしてみたいと思います!

トーストを表示したい機能は以下の通りです!

  • ログイン
  • ログアウト
  • 新規登録
  • 大学/学部/研究室の作成
  • 大学/学部/研究室の編集
  • レビュー投稿/編集/削除

2.1 ブランチ運用

例によって、developブランチを最新化させて、新しいブランチを切って作業をしようと思います。

ブランチ名は、feature/frontend/toastとかにします。

2.2 ライブラリインストール・セットアップ

そもそもトーストは、ユーザーの操作の結果を画面にポップに表示するものです。

焼きたてのトーストがトースターから飛び出るように、画面の端から飛び出てくるイメージです。

一から実装してもよいのですが、今回は既に先人が作ってくれている便利なライブラリをインストールしてそれを使うようにしたいと思います。

sonnerインストール

調べてみたところ、sonnerというものが使いやすく、Inertia.jsやtailwind CSSに相性が良さそうなので、今回はこれを使ってみたいと思います。

Dockerコンテナの中で以下を実行しましょう。

実行コマンド

/var/www
$ npm install sonner

package.jsonに追加されていることを確認しましょう。

\project-root\src\package.json
{
    "dependencies": {
        "chart.js": "^4.5.1",
        "dayjs": "^1.11.18",
        "react-chartjs-2": "^5.3.1",
        "sonner": "^2.0.7"
    }
}

フラッシュメッセージをグローバルpropsとして登録

Inertiaの設定を変更して、フラッシュメッセージ(先ほどコントローラーで作っていた'success'とか``'error'など)をプロジェクト内でpropsとして共有できるようにします。

既に、認証情報を取得する際に利用している機能ですが、認証情報に加えてフラッシュメッセージ情報も共有できるように追加します。

app/Http/Middleware/HandleInertiaRequests.php

クリックでコードを見る
\project-root\src\app\Http\Middleware\HandleInertiaRequests.php
    public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            'auth' => [
                'user' => $request->user(),
            ],
            // 以下を追加
            'flash' => [
                'success' => fn () => $request->session()->get('success'),
                'error' => fn () => $request->session()->get('error'),
            ],
        ];
    }

ここまでできたらコミットしておきましょう。

2.3 トースト表示

最後に、トーストを実際に画面に表示できるようにしましょう。

sonnerからToasterをインポートして、App.jsxで使います。

このToasterが実際にトーストの描画を行います。

  • resources/js/app.jsx
クリックでコードを見る
\project-root\src\resources\js\app.jsx
import '../css/app.css';
import './bootstrap';

import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

createInertiaApp({
  title: title => `${title} - ${appName}`,
  resolve: name =>
    resolvePageComponent(`./Pages/${name}.jsx`, import.meta.glob('./Pages/**/*.jsx')),
  setup({ el, App, props }) {
    const root = createRoot(el);

    root.render(
      <>
        <App {...props} />
        <Toaster />
      </>
    );
  },
  progress: {
    color: '#4B5563',
  },
});

次に、先ほどshare()に追加してグローバルに使えるようにしたusePage().props;を使って、フラッシュメッセージをsonnerに登録するためのコンポーネントファイルを新規作成しましょう。

前述の通り、実際の描画自体はToasterが行うので、このコンポーネントは画面描画を返しません(少し変な感じもするかもしれませんが)。

  • resources/js/Components/Common/FlashToast.jsx
クリックでコードを見る
\project-root\src\resources\js\Components\Common\FlashToast.jsx
import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
import { toast } from 'sonner';

const FlashToast = () => {
  const { flash } = usePage().props;

  useEffect(() => {
    if (flash.success) {
      toast.success(flash.success);
    }
    if (flash.error) {
      toast.error(flash.error);
    }
  }, [flash.success, flash.error]);

  return null; // 描画はsonnerの<Toaster />が担当
};

export default FlashToast;

最後にこれを呼び出して使えるようにします。
ただし、usePage()Appコンポーネント配下でないと使えないため、その子どものAppLayout.jsxで呼び出します。

  • resources/js/Layouts/AppLayout.jsx
クリックでコードを見る
\project-root\src\resources\js\Layouts\AppLayout.jsx
import { Head, usePage } from '@inertiajs/react';
import { useState, useCallback, useEffect } from 'react';
import Header from "../Components/Header";
import Sidebar from "../Components/Sidebar";
import HamburgerMenu from '@/Components/HamburgerMenu';
import AuthModal from '@/Components/Auth/AuthModal';
import FlashToast from '../Components/Common/FlashToast';

/**
 * アプリケーションのレイアウトコンポーネント
 * @param {Object} props - コンポーネントのprops
 * @param {React.ReactNode} props.children - レイアウト内に表示するコンテンツ
 * @param {string} props.title - ページタイトル
 * @param {string} [props.mode='default'] - レイアウトモード
 * @param {React.ReactNode} [props.headerRight] - ヘッダー右側に表示する追加コンテンツ
 * @returns {JSX.Element} コンポーネントのJSX
 */
const AppLayout = ({ children, title, mode='default', headerRight }) => {
  // ユーザーの認証状態を管理
  const { props } = usePage();
  const isLoggedIn = !!props?.auth?.user;

  // サイドバーの開閉状態を管理
  const [isSidebarOpen, setIsSidebarOpen] = useState(false);
  const setSidebarOpen = useCallback((open) => setIsSidebarOpen(Boolean(open)), []);

  // ホームページかどうか
  const isHome = mode === 'home';

  // 認証モーダルの状態('login' | 'register' | null)を管理
  const [authModal, setAuthModal] = useState(null);

  //  サイドバーが開いている間は、背景のスクロールを防止
  useEffect(() => {
    if (isSidebarOpen) document.body.classList.add('overflow-hidden');
    else document.body.classList.remove('overflow-hidden');
    return () => document.body.classList.remove('overflow-hidden');
  }, [isSidebarOpen]);

  //  Escキーでサイドバーを閉じる
  useEffect(() => {
    const onKey = (e) => e.key === 'Escape' && setIsSidebarOpen(false);
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  return (
    <div className="min-h-dvh flex flex-col bg-[#EEF5F9]">
      <Head title={title} />

      {/* ホームモード: ハンバーガーアイコンのみを固定表示(サイドバー非表示時のみ) */}
      {isHome ? (
        !isSidebarOpen && (
          <div className="fixed top-4 right-6 z-50">
            <HamburgerMenu onOpenSidebar={() => setSidebarOpen(true)} />
          </div>
        )
      ) : (
        /* デフォルト: フルヘッダー表示 */
        <Header title={title} onOpenSidebar={() => setIsSidebarOpen(true)} headerRight={headerRight} />
      )}

      {/* メインコンテンツ領域 */}
      <main className="flex-1 bg-transparent flex">
        <div
          className="
            mx-auto
            w-full
            max-w-container
            px-[clamp(16px,4vw,32px)]
            py-6
            bg-[#EEF5F9]
            flex-1
          "
        >
          {children || <p className="text-gray-400 text-center">メインコンテンツ領域</p>}
        </div>
      </main>

      {/* サイドバー */}
      <Sidebar
        isOpen={isSidebarOpen}
        onClose={() => setSidebarOpen(false)}
        isLoggedIn={isLoggedIn}
        onOpenAuthModal={setAuthModal}
      />

      {/* 認証モーダル */}
      <AuthModal
        mode={authModal}
        onClose={() => setAuthModal(null)}
        switchMode={(mode) => setAuthModal(mode)}
      />

      <FlashToast />
    </div>
  );
}

export default AppLayout;

2.4 動作確認

適当なユーザーでログインして試しにブックマークを登録してみましょう。

以下のように出ていればOK!

image.png

また、失敗系は再現するのが難しいので、一時的にコントローラー内の条件分岐を通します(動作確認が終わったら戻しておいてください。ちなみにルート名が間違っていたのでさりげなく修正しておきました。'lab.show' => 'labs.show'

\project-root\src\app\Http\Controllers\BookmarkController.php
        if (true) {
            return redirect()->route('labs.show', $request->input('lab_id'))
                ->with('error', 'この研究室は既にブックマークされています。');
        }

image.png

トーストが表示されることを確認したら、コミット・プッシュ、PR作成・マージを行いましょう。

3. (おまけその11)バックエンドPHPDocその3

久々のおまけコーナーですw

今日は、バックエンドのコントローラーにPHPDocを付ける話の第3弾です。

前回は、研究室コントローラーまでできたので、その続きを行っていきます。

3.1 ブランチ運用

前回と同様にrefactor/php-doc/controllersにチェックアウトして作業を進めます。

3.2 ブックマークコントローラー修正

app/Http/Controllers/BookmarkController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\BookmarkController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse; // 追加

class BookmarkController extends Controller
{
    use AuthorizesRequests;

    /**
     * ブックマークを保存する
     */
    public function store(Request $request): RedirectResponse
    {
    
    }

    /**
     * ブックマークを削除する
     */
    public function destroy(Bookmark $bookmark): RedirectResponse
    {
    
    }
}

3.2 コメントコントローラー修正

app/Http/Controllers/CommentController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\CommentController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse; // 追加

class CommentController extends Controller
{
    use AuthorizesRequests;

    // create, storeは削除

    /**
     * コメントを保存する
     */
    public function store(Request $request, Lab $lab): RedirectResponse
    {

    }

    /**
     * コメントを更新する
     */
    public function update(Request $request, Comment $comment): RedirectResponse
    {

    }

    /**
    * コメントを削除する
    */
    public function destroy(Comment $comment): RedirectResponse
    {

    }

    /**
     * コメントの一覧を取得する
     */
    public function index(Lab $lab, Request $request): Response
    {

    }
}

ついでに必要なくなった以下のファイルとcommentフォルダ自体を削除しておきましょう。

  • resources/js/Pages/Comment/Create.jsx
  • resources/js/Pages/Comment/Edit.jsx

3.3 ホームコントローラー修正

app/Http/Controllers/HomeController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\HomeController.php
<?php

namespace App\Http\Controllers;

use Inertia\Inertia;
use Inertia\Response;

class HomeController extends Controller
{
    /**
     * ホームページを表示する
     */
    public function home(): Response
    {
        return Inertia::render('Home');
    }
}

3.4 レビューコントローラー修正

app/Http/Controllers/ReviewController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\ReviewController.php
<?php

namespace App\Http\Controllers;

use App\Models\Lab;
use App\Models\Review;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class ReviewController extends Controller
{
    use AuthorizesRequests;

    /**
     * レビューを作成する
     */
    public function store(Request $request, Lab $lab): RedirectResponse
    {

    }

    /**
     * レビューを更新する
     */
    public function update(Request $request, Review $review): RedirectResponse
    {

    }

    /**
     * レビューを削除する
     */
    public function destroy(Review $review): RedirectResponse
    {

    }

    // 不要なメソッドは削除
}

ついでに必要のなくなっているページコンポーネントも削除します。

  • resources/js/Pages/Review/Create.jsx
  • resources/js/Pages/Review/Edit.jsx

ここまでできたら、コミットしましょう。

3.5 コンフリクト解消

気が付いたと思いますが、今修正したコントローラーのうちいくつかは古い状態になっていました。

というのも、同時並行でdevelopブランチがどんどん最新化されていって、このrefactor/php-doc/controllersブランチの情報が古くなっていったからです。

そのため、そのままプルリクエストを作成しようとしてもコンフリクトが発生してできません。

それ自体は悪いことではありません。

チームで開発していれば、このようなケースはよく起きます。

今回は、このシリーズでこれまで経験がなかったため、コンフリクトを解消するという経験をするためにあえてコンフリクトを起こしたというわけです。

リモートブランチの最新情報を取得

refactor/php-doc/controllersにチェックアウトしている状態で、以下のコマンドを実行して、リモートの最新状態を取得します(これだけでは、マージ等はされません)。

実行コマンド

/project-root/src
git fetch origin

developを最新化

developブランチにチェックアウトして、最新化します。
実行コマンド

/project-root/src
git checkout develop
/project-root/src
git pull origin develop

rebase

mergeでもよいのですが、今回はrebaseしてみましょう。

rebaseはコミット履歴をきれいに一直線にできます。

詳しくは、以下を読んでみてください。
https://qiita.com/72_mikan/items/0171795e9f20fe95f165

実行コマンド

/project-root/src
git rebase develop

すると以下のように表示されます。
CONFLICT (content): に表示されるのがコンフリクトが発生しているファイルです。

image.png

コンフリクト解消

該当ファイルを開くと以下のように、なります。
rebaseした内容がHEADに、手元の作業で変更していた内容が入力側の変更として表示されます。

image.png

これらの<<<>>>を削除して、手動で両者の変更をいい感じに取り込んだ状態を作ります。

app/Http/Controllers/CommentController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\CommentController.php
<?php

namespace App\Http\Controllers;

use App\Models\Comment;
use App\Models\Lab;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth as FacadesAuth;

class CommentController extends Controller
{
    use AuthorizesRequests;

    /**
     * コメントを保存する
     */
    public function store(Request $request, Lab $lab): JsonResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('create', Comment::class);

        // バリデーション
        $validated = $request->validate([
            'content' => 'required|string|max:1000',
        ]);

        // バリデーション済みのデータを保存
        $comment = new Comment();
        $comment->user_id = FacadesAuth::id();
        $comment->lab_id = $lab->id;
        $comment->content = $validated['content'];
        $comment->save();

        return response()->json($comment->load('user'), 201);
    }

    /**
     * コメントを更新する
     */
    public function update(Request $request, Comment $comment): JsonResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('update', $comment);

        // バリデーション
        $validated = $request->validate([
            'content' => 'required|string|max:1000',
        ]);

        // バリデーション済みのデータを更新
        $comment->content = $validated['content'];
        $comment->save();

        return response()->json($comment->load('user'));
    }

    /**
    * コメントを削除する
    */
    public function destroy(Comment $comment): JsonResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('delete', $comment);

        // コメントを削除
        $comment->delete();

        return response()->json(['message' => 'コメントが削除されました。']);
    }

    /**
     * コメントの一覧を取得する
     */
    public function index(Lab $lab, Request $request): JsonResponse
    {
        // 取得上限数を定義する
        $limit = min((int) $request->input('limit', 20), 50);
        // カーソル(どこまで進んでいるか)を定義
        $cursor = $request->input('cursor');

        // コメントのクエリを作成
        $query = $lab->comments()
        ->with('user')
        ->latest()
        ->orderBy('id', 'desc');

        // データの開始位置をカーソルで指定
        // 念のため、created_at と id の組み合わせでカーソルを処理
        if ($cursor) {
            $cursorComment = Comment::find($cursor);
            if ($cursorComment) {
                $query->where(function ($q) use ($cursorComment) {
                    $q->where('created_at', '<', $cursorComment->created_at)
                    ->orWhere(function ($q2) use ($cursorComment) {
                        $q2->where('created_at', '=', $cursorComment->created_at)
                            ->where('id', '<', $cursorComment->id);
                    });
                });
            }
        }

        $comments = $query->limit($limit + 1)->get();

        // 次のページがあるかどうかを判定
        $hasMore = $comments->count() > $limit;
        if ($hasMore) {
            $comments = $comments->slice(0, $limit);
        }

        return response()->json([
            'comments' => $comments->values(),
            'hasMore' => $hasMore,
            'nextCursor' => $comments->last()?->id,
        ]);
    }
}

app/Http/Controllers/ReviewController.php

クリックでコードを見る
\project-root\src\app\Http\Controllers\ReviewController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\ReviewRatingRequest;
use App\Models\Lab;
use App\Models\Review;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;

class ReviewController extends Controller
{
    use AuthorizesRequests;

    /**
     * レビューを作成する
     */
    public function store(ReviewRatingRequest $request, Lab $lab): RedirectResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('create', [Review::class, $lab]);
        $validated = $request->validated();

        // バリデーション済みのデータを保存
        $review = new Review();
        $review->user_id = Auth::id();
        $review->lab_id = $lab->id;
        $review->mentorship_style = $validated['mentorship_style'];
        $review->lab_atmosphere = $validated['lab_atmosphere'];
        $review->achievement_activity = $validated['achievement_activity'];
        $review->constraint_level = $validated['constraint_level'];
        $review->facility_quality = $validated['facility_quality'];
        $review->work_style = $validated['work_style'];
        $review->student_balance = $validated['student_balance'];
        $review->save();

        return redirect()->route('labs.show', ['lab' => $lab])->with('success', 'レビューが保存されました。');
    }

    /**
     * レビューを更新する
     */
    public function update(ReviewRatingRequest $request, Review $review): RedirectResponse
    {

        // ポリシーで認可をチェック
        $this->authorize('update', $review);
        $validated = $request->validated();

        // バリデーション済みのデータを更新
        $review->mentorship_style = $validated['mentorship_style'];
        $review->lab_atmosphere = $validated['lab_atmosphere'];
        $review->achievement_activity = $validated['achievement_activity'];
        $review->constraint_level = $validated['constraint_level'];
        $review->facility_quality = $validated['facility_quality'];
        $review->work_style = $validated['work_style'];
        $review->student_balance = $validated['student_balance'];
        $review->save();

        return redirect()->route('labs.show', ['lab' => $review->lab_id])->with('success', 'レビューが更新されました。');
    }

    /**
     * レビューを削除する
     */
    public function destroy(Review $review): RedirectResponse
    {
        // ポリシーで認可をチェック
        $this->authorize('delete', $review);

        $labId = $review->lab_id;

        $review->delete();
        return redirect()->route('labs.show', ['lab' => $labId])->with('success', 'レビューが削除されました。');
    }
}

rebase続き

コンフリクトが起きたファイルは以下のように表示されます。

これらをaddすることで、コンフリクト解消をgitに知らせます。
image.png

実行コマンド

/project-root/src
git add app/Http/Controllers/CommentController.php
/project-root/src
git add app/Http/Controllers/ReviewController.php

そうしたら、中断されていたrebaseを再開します。

実行コマンド

/project-root/src
git rebase continue

これで、pushできるようになりました!

push

実行コマンド

/project-root/src
git push origin refactor/php-doc/controllers --force-with-lease

ここで、先ほどの記事を読んでみましょう。

rebaseを使うときはgitにpushしていないブランチで行うようにしましょう。

はい、やってしまいました。
このブランチで過去にpushしたことをすっかり忘れてrebaseしてしまいました。

本当はあまりよくないのですが、強制pushするためにオプションを付けています。

※rebaseによって、ハッシュ値が変わってしまうため、リモート側がpushを受け付けてくれなくなるため

PR作成・マージ

これにて、いつも通り、githubにてPR作成・マージができます!
お忘れなく。

4. まとめ・次回予告

今回は、トーストを作成しました。

次回は、退会ページ作成に取り組みます。
フロントエンド編も残り3回です!

がんばりましょう~

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?