実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その39)
0. 初めに
こんにちは!
Webアプリケーションの作り方をゼロから解説しているシリーズです。
フロントエンド編も残りあとわずかとなりました!
今回は、トーストを作成したいと思います。
なんだかお腹がすいてきますね。(笑)
1. 下準備
いきなりトーストを作成する前に下準備をします。
1.1 ルーティング整理
ルーティングが書かれているweb.phpですが、使われていないものが残っていたたりして可読性が低い状態です。
まずは、これを直しましょう。
ブランチ運用
例によって、developブランチを最新化させて、新しいブランチを切って作業をしようと思います。
ブランチ名は、refactor/routingとかにしましょう。
名前付きルーティングの単・複統一
例えば、以下をご覧ください。
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
クリックでコードを見る
<?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
クリックでコードを見る
await axios.put(route('comments.update', editingCommentId), {
resources/js/Components/Faculty/CreateFacultyModal.jsx
クリックでコードを見る
post(route('faculties.store', university.id), {
resources/js/Components/Faculty/EditFacultyModal.jsx
クリックでコードを見る
put(route('faculties.update', faculty.id), {
resources/js/Components/Lab/CreateLabModal.jsx
クリックでコードを見る
post(route('labs.store', faculty.id), {
resources/js/Components/Review/CreateReviewModal.jsx
クリックでコードを見る
post(route('reviews.store', lab.id), {
resources/js/Components/Lab/EditLabModal.jsx
クリックでコードを見る
put(route('labs.update', lab.id), {
resources/js/Components/Review/EditReviewModal.jsx
クリックでコードを見る
put(route('reviews.update', review.id), {
resources/js/Components/University/CreateUniversityModal.jsx
クリックでコードを見る
post(route('universities.store'), {
resources/js/Components/University/EditUniversityModal.jsx
クリックでコードを見る
put(route('universities.update', university.id), {
resources/js/Pages/Faculty/Index.jsx
クリックでコードを見る
router.get(route('universities.history', { university: university.id }), { query });
resources/js/Pages/Lab/Index.jsx
クリックでコードを見る
router.get(route('faculties.history', { faculty: faculty.id }), { query });
resources/js/Pages/Lab/Show.jsx
クリックでコードを見る
router.delete(route('bookmarks.destroy', bookmarkId), {
ここまでできたらいったんコミットしておきましょう。
不要なルーティング削除
これまで作成・編集系はモーダルで行うように変更してきたので、それら用のページを表示するためのルーティングは必要なくなったので削除します。
削除後のweb.phpは以下のようになります。
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
クリックでコードを見る
<?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
クリックでコードを見る
<?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
クリックでコードを見る
<?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
クリックでコードを見る
<?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
クリックでコードを見る
<?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
クリックでコードを見る
<?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
クリックでコードを見る
<?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
ログインを対応したので、当然新規登録の方も対応していきましょう。
クリックでコードを見る
<?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が残っていたので直します。
クリックでコードを見る
public function deleteUser()
{
/** @var User $user */
$user = Auth::user();
$user->delete();
return redirect()->route('home')->with('success', 'アカウントを削除しました');
}
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に書いてきましたが、認証に関係するルーティングはこのファイルに書き込みます。
クリックでコードを見る
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内で暫定的に残しておいた部分もそれに伴って削除してください。
// 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
クリックでコードを見る
<?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')
よって以下のように、修正してください。
クリックでコードを見る
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
クリックでコードを見る
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
学部コントローラーも大学と同じノリで直します。
クリックでコードを見る
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
研究室コントローラーも直します。
クリックでコードを見る
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コンテナの中で以下を実行しましょう。
実行コマンド
$ npm install sonner
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
クリックでコードを見る
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
クリックでコードを見る
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
クリックでコードを見る
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
クリックでコードを見る
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!
また、失敗系は再現するのが難しいので、一時的にコントローラー内の条件分岐を通します(動作確認が終わったら戻しておいてください。ちなみにルート名が間違っていたのでさりげなく修正しておきました。'lab.show' => 'labs.show')
if (true) {
return redirect()->route('labs.show', $request->input('lab_id'))
->with('error', 'この研究室は既にブックマークされています。');
}
トーストが表示されることを確認したら、コミット・プッシュ、PR作成・マージを行いましょう。
3. (おまけその11)バックエンドPHPDocその3
久々のおまけコーナーですw
今日は、バックエンドのコントローラーにPHPDocを付ける話の第3弾です。
前回は、研究室コントローラーまでできたので、その続きを行っていきます。
3.1 ブランチ運用
前回と同様にrefactor/php-doc/controllersにチェックアウトして作業を進めます。
3.2 ブックマークコントローラー修正
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
クリックでコードを見る
<?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.jsxresources/js/Pages/Comment/Edit.jsx
3.3 ホームコントローラー修正
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
クリックでコードを見る
<?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.jsxresources/js/Pages/Review/Edit.jsx
ここまでできたら、コミットしましょう。
3.5 コンフリクト解消
気が付いたと思いますが、今修正したコントローラーのうちいくつかは古い状態になっていました。
というのも、同時並行でdevelopブランチがどんどん最新化されていって、このrefactor/php-doc/controllersブランチの情報が古くなっていったからです。
そのため、そのままプルリクエストを作成しようとしてもコンフリクトが発生してできません。
それ自体は悪いことではありません。
チームで開発していれば、このようなケースはよく起きます。
今回は、このシリーズでこれまで経験がなかったため、コンフリクトを解消するという経験をするためにあえてコンフリクトを起こしたというわけです。
リモートブランチの最新情報を取得
refactor/php-doc/controllersにチェックアウトしている状態で、以下のコマンドを実行して、リモートの最新状態を取得します(これだけでは、マージ等はされません)。
実行コマンド
git fetch origin
developを最新化
developブランチにチェックアウトして、最新化します。
実行コマンド
git checkout develop
git pull origin develop
rebase
mergeでもよいのですが、今回はrebaseしてみましょう。
rebaseはコミット履歴をきれいに一直線にできます。
詳しくは、以下を読んでみてください。
https://qiita.com/72_mikan/items/0171795e9f20fe95f165
実行コマンド
git rebase develop
すると以下のように表示されます。
CONFLICT (content): に表示されるのがコンフリクトが発生しているファイルです。
コンフリクト解消
該当ファイルを開くと以下のように、なります。
rebaseした内容がHEADに、手元の作業で変更していた内容が入力側の変更として表示されます。
これらの<<<や>>>を削除して、手動で両者の変更をいい感じに取り込んだ状態を作ります。
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
クリックでコードを見る
<?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に知らせます。

実行コマンド
git add app/Http/Controllers/CommentController.php
git add app/Http/Controllers/ReviewController.php
そうしたら、中断されていたrebaseを再開します。
実行コマンド
git rebase continue
これで、pushできるようになりました!
push
実行コマンド
git push origin refactor/php-doc/controllers --force-with-lease
ここで、先ほどの記事を読んでみましょう。
rebaseを使うときはgitにpushしていないブランチで行うようにしましょう。
はい、やってしまいました。
このブランチで過去にpushしたことをすっかり忘れてrebaseしてしまいました。
本当はあまりよくないのですが、強制pushするためにオプションを付けています。
※rebaseによって、ハッシュ値が変わってしまうため、リモート側がpushを受け付けてくれなくなるため
PR作成・マージ
これにて、いつも通り、githubにてPR作成・マージができます!
お忘れなく。
4. まとめ・次回予告
今回は、トーストを作成しました。
次回は、退会ページ作成に取り組みます。
フロントエンド編も残り3回です!
がんばりましょう~
これまでの記事一覧
☆要件定義・設計編
☆環境構築編
☆バックエンド実装編
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~
- その17: バックエンド実装編⑪ ~排他制御・トランザクション処理~
- その18: バックエンド実装編⑫ ~マイページ機能作成~
- その19: バックエンド実装編⑬ ~管理者アカウント機能作成~
- その20: バックエンド実装編⑭ ~通知機能作成~
- その21: バックエンド実装編⑮ ~ソーシャルログイン機能作成~
☆フロントエンド実装編
- その22: フロントエンド実装編① ~メインコンテンツ領域作成~
- その23: フロントエンド実装編② ~ヘッダー作成~
- その24: フロントエンド実装編③ ~サイドバー作成~
- その25: フロントエンド実装編④ ~ホームページ作成~
- その26: フロントエンド実装編⑤ ~大学検索結果画面作成~
- その27: フロントエンド実装編⑥ ~大学詳細・学部一覧画面作成~
- その28: フロントエンド実装編⑦ ~学部詳細・研究室一覧画面作成~
- その29: フロントエンド実装編⑧前編 ~研究室詳細・レビュー画面作成前編~
- その29.5: フロントエンド実装編⑧後編 ~研究室詳細・レビュー画面作成後編~
- その30: フロントエンド実装編⑨ ~マイページ作成~
- その31: フロントエンド実装編⑩ ~パンくずリスト作成~
- その32: フロントエンド実装編⑪ ~ログイン・新規登録モーダル作成~
- その33: フロントエンド実装編⑫ ~大学作成・編集モーダル作成~
- その34: フロントエンド実装編⑬ ~学部作成・編集モーダル作成~
- その35: フロントエンド実装編⑭ ~研究室作成・編集モーダル作成~
- その36: フロントエンド実装編⑮ ~レビュー作成・編集モーダル作成~
- その37: フロントエンド実装編⑯ ~コメント一覧表示モーダル作成~
- その38: フロントエンド実装編⑰ ~編集履歴ページ・削除依頼ページ・通知ドロップダウン作成~
軽く宣伝
YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。
現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"



