0
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?

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(バックエンド実装編⑧)~コメント投稿機能~

Last updated at Posted at 2025-08-14

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その14)

0. 初めに

こんにちは!

先週は投稿をさぼってしまってすみませんでした。
一応毎週木曜日投稿という形でやっていましたが、先週は投稿できませんでした。
記事自体は完成していたのですが、投稿を完全にド忘れしてしまいました...💦

気を取り直して、、
このシリーズでは、実務未経験者の僕が頑張ってWebアプリケーションを開発する様子をお届けしています。

初心者の方でもわかるように丁寧に解説してきているので、よかったらこれまでの記事も併せて読んでみ見てください!

今日は、コメント機能を作りたいと思います。
前回の記事の分量がとてつもなくなってしまったので、今日の目標はコンパクトな記事を書くことです!(笑)

それでは、やっていきましょう~('ω')

1. ブランチ運用

例によって、develop ブランチを最新化してから、新しいブランチを切ります。

ブランチ名: feature/12-comment

2. マイグレーション実行

テーブル設計を確認する

image.png
最初にテーブル構造を確認しておきましょう!
もう覚えていないと思いますが(笑)、一番最初の設計編のときにはコメントの文章を body という名前のカラムに保存しようとしていました。
しかし、content の方が分かりやすいかなと思い変更しました。

コメントは、「誰が投稿するのか」と「どの研究室に対して投稿するのか」という二つの情報が必要です。
さらに、一人のユーザーは複数のコメントを投稿できるのに対し、一つの大学には複数のコメントを投稿することができるため、多対多の関係になっていますね。
そのため、このコメントテーブルが中間テーブルの役割を果たしています。

マイグレーションを作成・実行する

以下のコマンドでコメントテーブルを作成するためのマイグレーションファイルを作成しましょう。
実行コマンド

/var/www
$ php artisan make:migration create_comments_table

できたら、up() メソッドを以下のようにしてください。

\project-root\src\database\migrations\2025_07_14_200540_create_comments_table.php
public function up(): void
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('lab_id')->constrained('labs')->onDelete('cascade');
            $table->text('content');
            $table->timestamps();
        });
    }

かなり今頃ですが、この :void っていうのは「返り値がありません」という意味です。
他にも :string とすれば戻り値を文字列型に指定できます。
つまり、phpは実は、他言語のJavaやTypeScriptのように「型の指定」ができる言語です
今まで素通りしてきましたが、これによりこれによりバグの防止や発見のしやすさにつながるらしいです。

できたら、以下のコマンドで実行してください。

実行コマンド

/var/www
$ php artisan migrate

これもかなり今更ですが、過去にこのシリーズで僕はこのコマンドを「artisanコマンドです!」と紹介しましたが、厳密には違うっぽいです💦
こちらの php artisanの意味わかってる? という記事によれば、artisan というのはあくまでオプションで、これ自体はphpコマンドだそうです。(._.)

何はともあれ、テーブルが作成されていいればOKです。
image.png

3. モデル作成

次に、モデルを作成します。

実行コマンド

/var/www
$ php artisan make:model Comment
\project-root\src\app\Models\Comment.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
    protected $fillable = [
        'user_id',
        'lab_id',
        'content',
    ];

    // リレーション
    // ここでは一対多
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function lab()
    {
        return $this->belongsTo(Lab::class);
    }
}

また、ユーザーモデルにも以下を追加してください。

\project-root\src\app\Models\User.php
// 追加: コメントとのリレーション(一対多)
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

さらに、研究室モデルにも追加です。

\project-root\src\app\Models\Lab.php
// 追加: コメントとのリレーション(一対多)
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

ここで思ったことがあると思います!

中間テーブルなんだから、前回みたいに belongsToMany() って書くんじゃないの!?

確かにLaravelでは中間テーブルをそのように書くこともできます!
ただ、前回もう一つポイントをお話ししたと思います。
それが...

多対多のリレーションは理想的ではない。
一対多のリレーションが理想である。
中間テーブルを用いることで、多対多を一対多に置き換えることができる。

よく見てみると、コメントテーブルの方では、belongsTo() を使い、一方で、ユーザー・研究室の方では、hasMany() を用いています。
これにより、多対多の関係を一つのテーブルをはさむことで、一対多の関係に緩和しています。

したがって、belogsToMany() を使わなくても中間テーブルの形を実現することができています!

4. コントローラー作成

phpのコンテナ内でいつも通り、以下のコマンドで作成してください。

/var/www
$ php artisan make:controller CommentController
\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\Request;
use Illuminate\Support\Facades\Auth as FacadesAuth;
use Inertia\Inertia;

class CommentController extends Controller
{
    use AuthorizesRequests;

    public function create(Lab $lab)
    {
        // ポリシーで認可をチェック
        $this->authorize('create', Comment::class);
        return Inertia::render('Comment/Create', [
            'lab' => $lab,
        ]);
    }

    public function store(Request $request, Lab $lab)
    {
        // ポリシーで認可をチェック
        $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 redirect()->route('labs.show', ['lab' => $lab])->with('success', 'コメントが保存されました。');
    }
}

一応ポリシーも追加しておきますか。

/var/www
$ php artisan make:policy CommentPolicy
\project-root\src\app\Policies\CommentPolicy.php
<?php

namespace App\Policies;

use App\Models\User;

class CommentPolicy
{
    // ユーザーがコメントを作成できるかどうかを判定
    public function create(User $user)
    {
        // コメントを作成できるのはログインユーザーのみ
        return $user->exists;
    }
}

登録もお忘れなく!

\project-root\src\app\Providers\AppServiceProvider.php
<?php

namespace App\Providers;

use App\Models\Comment; // 追加
use App\Models\Faculty;
use App\Models\Lab;
use App\Models\Review;
use App\Models\University;
use App\Policies\CommentPolicy; // 追加
use App\Policies\FacultyPolicy;
use App\Policies\LabPolicy;
use App\Policies\ReviewPolicy;
use App\Policies\UniversityPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Vite::prefetch(concurrency: 3);

        // ポリシーを登録
        Gate::policy(Review::class, ReviewPolicy::class);
        Gate::policy(University::class, UniversityPolicy::class);
        Gate::policy(Faculty::class, FacultyPolicy::class);
        Gate::policy(Lab::class, LabPolicy::class);
        Gate::policy(Comment::class, CommentPolicy::class); // 追加
    }
}

5. ルーティング追加

Auth ミドルウェアの中に追加です!

\project-root\src\routes\web.php
// コメント関連
    Route::get('/labs/{lab}/comments/create', [CommentController::class, 'create'])->name('comment.create');
    Route::post('/labs/{lab}/comments', [CommentController::class, 'store'])->name('comment.store');

6. 作成ページ作成

最後に、作成ページのReactコンポーネントを作ります。

\project-root\src\resources\js\Pages\Comment\Create.jsx
import React, { useState } from 'react';
import { Head, useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

export default function Create({ auth, lab }) {
    const { data, setData, post, processing, errors } = useForm({
        content: '',
    });

    const submit = (e) => {
        e.preventDefault();
        post(route('comment.store', lab.id));
    };

    return (
        <AuthenticatedLayout
            user={auth.user}
            header={<h2>コメント投稿</h2>}
        >
            <Head title="コメント投稿" />

            <div>
                <h3>研究室: {lab.name}</h3>
                
                <form onSubmit={submit}>
                    <div>
                        <label htmlFor="content">コメント内容</label>
                        <textarea
                            id="content"
                            name="content"
                            value={data.content}
                            onChange={(e) => setData('content', e.target.value)}
                            rows="5"
                            cols="50"
                            required
                        />
                        {errors.content && <div>{errors.content}</div>}
                    </div>

                    <div>
                        <button type="submit" disabled={processing}>
                            {processing ? '投稿中...' : 'コメントを投稿'}
                        </button>
                        <a href={route('labs.show', lab.id)}>
                            <button type="button">キャンセル</button>
                        </a>
                    </div>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}

研究室一覧ページに一応コメント作成ページを開くボタンを付けておきますか!

\project-root\src\resources\js\Pages\Lab\Show.jsx
import React from 'react';
import { Head, Link, router } from '@inertiajs/react';

// propsとして新しく追加されたプロパティも受け取る
export default function Show({ 
    lab, 
    overallAverage, 
    averagePerItem, 
    userReview, 
    userOverallAverage, 
    ratingData
}) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

    // ratingColumnsが空の場合、フォールバック用の配列を使用
    const fallbackRatingColumns = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];
    
    const ratingColumns = ratingData?.columns || fallbackRatingColumns;
    const actualRatingColumns = ratingColumns.length > 0 ? ratingColumns : fallbackRatingColumns;

    const itemLabels = {
        mentorship_style: '指導スタイル',
        lab_atmosphere: '雰囲気・文化',
        achievement_activity: '成果・活動',
        constraint_level: '拘束度',
        facility_quality: '設備',
        work_style: '働き方',
        student_balance: '人数バランス',
    };

    const formatAverage = (value) => {
        return value !== null && value !== undefined ? value.toFixed(2) : 'データなし';
    };

    const handleDeleteReview = (reviewId) => {
        if (confirm('本当に削除してもよろしいですか?')) {
            router.delete(route('review.destroy', { review: reviewId }), {
                onSuccess: () => {
                    // 成功時の処理
                    alert('レビューが削除されました。');
                },
                onError: (error) => {
                    // エラー時の処理
                    alert('レビューの削除に失敗しました。');
                }
            });
        }
    };

    const handleCreateReview = () => {
        router.get(route('review.create', { lab: lab.id }));
    };

    const handleEditReview = () => {
        router.get(route('review.edit', { review: userReview.id }));
    };

    const handleCreateComment = () => {
        router.get(route('comment.create', { lab: lab.id }));
    };

    return (
        <div>
            <Head title={`${lab.name}の詳細`} />
            <h1>{lab.name} の詳細ページ</h1>
            
            {/* 研究室一覧に戻るボタン */}
            <div>
                <Link href={route('labs.index', lab.faculty_id)}>
                    <button>研究室一覧に戻る</button>
                </Link>
            </div>
            
            {/* 研究室編集ボタン */}
            <div>
                <Link href={route('lab.edit', lab.id)}>
                    <button>研究室を編集</button>
                </Link>
            </div>
            
            {/* 編集履歴ボタン */}
            <div>
                <Link href={route('lab.history', lab.id)}>
                    <button>編集履歴を見る</button>
                </Link>
            </div>
            
            {/* コメント投稿ボタン */}
            <div>
                <button onClick={handleCreateComment}>
                    コメントを投稿する
                </button>
            </div>
            
            <p>大学: {lab.faculty?.university?.name}</p>
            <p>学部: {lab.faculty?.name}</p>
            <p>研究室の説明: {lab.description}</p>
            <p>研究室のURL: <a href={lab.url} target="_blank" rel="noopener noreferrer">{lab.url}</a></p>
            <p>教授のURL: <a href={lab.professor_url} target="_blank" rel="noopener noreferrer">{lab.professor_url}</a></p>
            <p>男女比(男): {lab.gender_ratio_male}</p>
            <p>男女比(女): {lab.gender_ratio_female}</p>

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            
            {/* 全体の平均評価を表示 */}
            <h3>全体の評価(平均)</h3>
            <p><strong>総合評価: </strong>{formatAverage(overallAverage)}</p>

            <h4>各評価項目の平均:</h4>
            {averagePerItem && Object.keys(averagePerItem).length > 0 ? (
                <ul>
                    {Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
                        <li key={itemKey}>
                            <p>
                                <strong>{itemLabels[itemKey] || itemKey}:</strong>
                                {formatAverage(averageValue)}
                            </p>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>まだ評価データがありません。</p>
            )}

            {/* ユーザーのレビューが存在する場合に表示 */}
            {userReview ? (
                <div>
                    <h3>あなたの投稿したレビュー</h3>
                    <p><strong>総合評価: </strong>{formatAverage(userOverallAverage)}</p>
                    
                    <h4>各評価項目:</h4>
                    <ul>
                        {actualRatingColumns && actualRatingColumns.length > 0 ? (
                            actualRatingColumns.map((column) => {
                                const value = userReview[column];
                                return (
                                    <li key={column}>
                                        <p>
                                            <strong>{itemLabels[column] || column}:</strong>
                                            {value !== null && value !== undefined 
                                                ? (typeof value === 'number' ? value.toFixed(2) : value)
                                                : '未評価'}
                                        </p>
                                    </li>
                                )
                            })
                        ) : (
                            <li>評価項目データがありません</li>
                        )}
                    </ul>
                    
                    <div>
                        <button onClick={() => handleDeleteReview(userReview.id)}>
                            このレビューを削除
                        </button>
                        <button onClick={handleEditReview}>
                            レビューを編集する
                        </button>
                    </div>
                </div>
            ) : (
                // レビューが存在しない場合はレビュー投稿ボタンを表示
                <div>
                    <h3>レビューを投稿</h3>
                    <p>まだこの研究室のレビューを投稿していません。</p>
                    <button onClick={handleCreateReview}>
                        レビューを投稿する
                    </button>
                </div>
            )}
        </div>
    );
}

7. 動作確認

前回作成した「ほげほげ研究室」にコメントしてみましょう。
適当なユーザーでログインして、「コメントを投稿する」をクリックしてみます。
image.png

適当なコメントを書いて投稿ボタンをクリック!
image.png

データベースを見ると、レコードが登録されていることが分かります。
image.png
※研究室IDが皆さんの状況と違うと思いますが、気にしないでください。

8. まとめ・次回予告

今回は特に新しいこともなく、あっさりと終わりましたね。(笑)
次回はコメントの編集・削除機能を作成したいと思います。
また、投稿済みのコメントを表示するようにもなっていないので、次回それも併せて作りましょう。

コミット・プッシュもお忘れなく!
では、また!

これまでの記事一覧

--- 要件定義・設計編 ---

--- 環境構築編 ---

--- バックエンド実装編 ---

参考

0
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
0
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?