【Laravel】コメント機能を実装する|簡単な掲示板アプリの作成

Laravelで掲示板アプリにコメント機能を実装する方法を解説します。マイグレーションでテーブルを作成し、投稿・ユーザー・コメントの各モデルをリレーションで関連付けます。コントローラーでのコメント保存処理や、ビューでのコメント一覧と投稿フォームの作成まで、一連の流れを初心者にもわかりやすく説明します。

作成日: 更新日:

開発環境

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

サンプルコード

/app/Http/Controllers/CommentController.php

/app/Http/Controllers/CommentController.php
1+<?php
2+
3+namespace App\Http\Controllers;
4+
5+use App\Models\Comment;
6+use App\Models\Post;
7+use Illuminate\Http\Request;
8+use Illuminate\Support\Facades\Auth;
9+
10+class CommentController extends Controller
11+{
12+    public function store(Request $request, Post $post)
13+    {
14+        $request->validate([
15+            'content' => 'required|max:500'
16+        ]);
17+
18+        Comment::create([
19+            'post_id' => $post->id,
20+            'user_id' => Auth::id(),
21+            'content' => $request->content,
22+        ]);
23+
24+        return redirect()->route('posts.show', $post->id);
25+    }
26+}
27

/app/Http/Controllers/PostController.php

/app/Http/Controllers/PostController.php
1      */
2     public function show(string $id)
3     {
4-        $post = Post::findOrFail($id);
5+        $post = Post::with('comments.user')->findOrFail($id);
6         return view('posts.show', compact('post'));
7     }
8 
9

/app/Models/Comment.php

/app/Models/Comment.php
1+<?php
2+
3+namespace App\Models;
4+
5+use Illuminate\Database\Eloquent\Factories\HasFactory;
6+use Illuminate\Database\Eloquent\Model;
7+
8+class Comment extends Model
9+{
10+    use HasFactory;
11+
12+    protected $fillable = [
13+        'post_id',
14+        'user_id',
15+        'content'
16+    ];
17+
18+    public function post()
19+    {
20+        return $this->belongsTo(Post::class);
21+    }
22+
23+    public function user()
24+    {
25+        return $this->belongsTo(User::class);
26+    }
27+}
28

/app/Models/Post.php

/app/Models/Post.php
1         'content',
2         'user_id'
3     ];
4+
5+    public function comments()
6+    {
7+        return $this->hasMany(Comment::class);
8+    }
9 }
10

/app/Models/User.php

/app/Models/User.php
1             'password' => 'hashed',
2         ];
3     }
4+
5+    public function comments()
6+    {
7+        return $this->hasMany(Comment::class);
8+    }
9 }
10

/database/migrations/2024_11_01_114248_create_comments_table.php

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

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

/resources/views/posts/show.blade.php
1                 @endif
2         </div>
3     </div>
4+
5+    {{-- コメント一覧 --}}
6+    <h3>コメント一覧</h3>
7+    @if ($post->comments->isEmpty())
8+       <p>コメントはまだありません。</p>
9+    @else
10+        @foreach ($post->comments as $comment)
11+            <div class="card mb-2">
12+                <div class="card-body">
13+                    <p class="card-text">{{ $comment->content }}</p>
14+                    <p class="text-muted">投稿者: {{ $comment->user->name }} | 投稿日時: {{ $comment->created_at->format('Y-m-d H:i') }}</p>
15+                </div>
16+            </div>
17+        @endforeach
18+    @endif
19+
20+    {{-- コメントフォーム --}}
21+    <h3>コメントを投稿する</h3>
22+    @auth
23+        <form action="{{ route('comments.store', $post->id) }}" method="POST">
24+            @csrf
25+            <div class="mb-3">
26+                <label for="content" class="form-label">コメント内容</label>
27+                <textarea name="content" id="content" class="form-control" rows="3" required></textarea>
28+            </div>
29+            <button type="submit" class="btn btn-primary">コメント投稿</button>
30+        </form>
31+    @else
32+        <p>コメントを投稿するにはログインしてください。</p>
33+    @endauth
34 @endsection
35

/routes/web.php

/routes/web.php
1 
2 use App\Http\Controllers\ProfileController;
3 use App\Http\Controllers\PostController;
4+use App\Http\Controllers\CommentController;
5 use Illuminate\Support\Facades\Route;
6 
7 Route::get('/', function () {
8 // 編集・削除はログインが必要だが、権限はポリシーで制限
9 Route::resource('posts', PostController::class)->except(['create', 'store', 'index', 'show'])->middleware('auth');
10 
11+// コメントを投稿するルーティング
12+Route::post('posts/{post}/comments', [CommentController::class, 'store'])->name('comments.store');
13+
14 require __DIR__.'/auth.php';
15

コード解説

変更点: commentsテーブルの設計図(マイグレーション)を作成

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

このファイルは、データベースにcommentsテーブルを作成するための設計図です。これを「マイグレーションファイル」と呼びます。

upメソッドの中に、テーブルの構造を定義しています。

  • $table->id();:コメントを一位に識別するためのID(主キー)を作成します。
  • $table->foreignId('post_id')->constrained()->onDelete('cascade');:「どの投稿に対するコメントか」を記録するためのpost_idカラムを作成します。constrained()を付けることで、postsテーブルのidと関連付けられます。これを「外部キー制約」と呼びます。onDelete('cascade')は、関連する投稿が削除されたときに、このコメントも一緒に自動で削除されるようにする設定です。
  • $table->foreignId('user_id')->constrained()->onDelete('cascade');:「誰が投稿したコメントか」を記録するためのuser_idカラムを作成します。これも同様にusersテーブルのidと関連付けられています。
  • $table->text('content');:コメントの本文を保存するためのcontentカラムを作成します。text型は長い文章を保存できます。
  • $table->timestamps();:データの作成日時(created_at)と更新日時(updated_at)を自動で記録するカラムを追加します。

変更点: コメント情報を扱うCommentモデルを作成

/app/Models/Comment.php
1+<?php
2+
3+namespace App\Models;
4+
5+use Illuminate\Database\Eloquent\Factories\HasFactory;
6+use Illuminate\Database\Eloquent\Model;
7+
8+class Comment extends Model
9+{
10+    use HasFactory;
11+
12+    protected $fillable = [
13+        'post_id',
14+        'user_id',
15+        'content'
16+    ];
17+
18+    public function post()
19+    {
20+        return $this->belongsTo(Post::class);
21+    }
22+
23+    public function user()
24+    {
25+        return $this->belongsTo(User::class);
26+    }
27+}
28

このファイルは、先ほど作成したcommentsテーブルと連携するためのCommentモデルです。モデルは、データベースのテーブルと1対1で対応し、データの操作を簡単にする役割を持ちます。

  • protected $fillable = [...]Comment::create()のようなメソッドで、一度に複数のデータを保存・更新することを許可するカラムを指定します。ここに指定されていないカラムは、一括代入しようとしても無視されるため、セキュリティ対策になります。
  • public function post()Commentモデルから関連するPostモデルの情報を取得するための設定です。belongsToは「多対1」の関係を表し、「このコメントは1つの投稿(Post)に属している」という意味になります。これにより、$comment->postのようにして、コメントが紐づく投稿の情報を簡単に取得できます。
  • public function user():同様に、「このコメントは1人のユーザー(User)に属している」という関係を定義します。これにより、$comment->userでコメントを投稿したユーザーの情報を取得できます。

変更点: 投稿(Post)とコメント(Comment)の関連付け

/app/Models/Post.php
1         'content',
2         'user_id'
3     ];
4+
5+    public function comments()
6+    {
7+        return $this->hasMany(Comment::class);
8+    }
9 }
10

既存のPostモデルに、Commentモデルとの関連付けを追加しています。

  • public function comments()Postモデルから関連するCommentモデルの情報を取得するための設定です。hasManyは「1対多」の関係を表し、「1つの投稿(Post)はたくさんのコメント(Comment)を持つことができる」という意味になります。これにより、$post->commentsのようにして、特定の投稿に紐づく全てのコメントを一度に取得できるようになります。

変更点: ユーザー(User)とコメント(Comment)の関連付け

/app/Models/User.php
1             'password' => 'hashed',
2         ];
3     }
4+
5+    public function comments()
6+    {
7+        return $this->hasMany(Comment::class);
8+    }
9 }
10

既存のUserモデルにも、Commentモデルとの関連付けを追加しています。

  • public function comments()Userモデルから関連するCommentモデルの情報を取得するための設定です。Postモデルと同様にhasManyを使い、「1人のユーザー(User)はたくさんのコメント(Comment)を投稿できる」という関係を定義しています。これにより、$user->commentsで、特定のユーザーが投稿した全てのコメントを取得できます。

変更点: コメント投稿処理への道筋(ルーティング)を追加

/routes/web.php
1 
2 use App\Http\Controllers\ProfileController;
3 use App\Http\Controllers\PostController;
4+use App\Http\Controllers\CommentController;
5 use Illuminate\Support\Facades\Route;
6 
7 Route::get('/', function () {
8 // 編集・削除はログインが必要だが、権限はポリシーで制限
9 Route::resource('posts', PostController::class)->except(['create', 'store', 'index', 'show'])->middleware('auth');
10 
11+// コメントを投稿するルーティング
12+Route::post('posts/{post}/comments', [CommentController::class, 'store'])->name('comments.store');
13+
14 require __DIR__.'/auth.php';
15

このファイルは、WebアプリケーションのURLと、そのURLにアクセスされたときに実行する処理を結びつける「ルーティング」を設定します。

  • use App\Http\Controllers\CommentController;:これから作成するCommentControllerを使うための宣言です。
  • Route::post(...):HTTPのPOSTメソッド(主にフォーム送信などでデータを送るときに使う)でのアクセスに対するルートを定義しています。
  • 'posts/{post}/comments':URLのパスを指定しています。{post}の部分は可変で、どの投稿に対するコメントなのかを示すIDが入ります。例えば、IDが1の投稿へのコメントならURLは/posts/1/commentsとなります。
  • [CommentController::class, 'store']:このURLにアクセスされたときに、CommentControllerというクラスのstoreというメソッドを実行するように指定しています。
  • ->name('comments.store'):このルートにcomments.storeという名前を付けています。これにより、ビューファイルなどでURLを直接書く代わりにroute('comments.store')と書けるようになり、後からURLを変更しても修正箇所が少なくて済みます。

変更点: コメントをデータベースに保存する処理を実装

/app/Http/Controllers/CommentController.php
1+<?php
2+
3+namespace App\Http\Controllers;
4+
5+use App\Models\Comment;
6+use App\Models\Post;
7+use Illuminate\Http\Request;
8+use Illuminate\Support\Facades\Auth;
9+
10+class CommentController extends Controller
11+{
12+    public function store(Request $request, Post $post)
13+    {
14+        $request->validate([
15+            'content' => 'required|max:500'
16+        ]);
17+
18+        Comment::create([
19+            'post_id' => $post->id,
20+            'user_id' => Auth::id(),
21+            'content' => $request->content,
22+        ]);
23+
24+        return redirect()->route('posts.show', $post->id);
25+    }
26+}
27

このファイルは、コメントの投稿処理を担当するCommentControllerです。先ほどルーティングで設定した処理の本体となります。

  • public function store(Request $request, Post $post)storeメソッドを定義しています。引数にRequest $requestと書くことで、フォームから送信されたデータを受け取れます。Post $postと書くことで、URLの{post}部分のIDに該当するPostモデルのインスタンスが自動的に取得できます。これを「ルートモデルバインディング」と呼びます。
  • $request->validate(...):フォームから送られてきたデータが正しいか検証(バリデーション)します。'content' => 'required|max:500'は、「contentという名前の入力フィールドは必須入力(required)で、最大500文字まで(max:500)」というルールを定義しています。ルールに違反した場合は、エラーメッセージと共に自動的に前のページに戻ります。
  • Comment::create([...])commentsテーブルに新しいレコードを作成します。
    • 'post_id' => $post->id,:コメントがどの投稿に紐づくかを示すpost_idには、URLから取得した投稿のIDを設定します。
    • 'user_id' => Auth::id(),:コメントを投稿したユーザーのIDには、Auth::id()を使って現在ログインしているユーザーのIDを設定します。
    • 'content' => $request->content,:コメント本文には、フォームから送信されたcontentの値を設定します。
  • return redirect()->route('posts.show', $post->id);:コメントの保存が完了したら、元の投稿詳細ページにリダイレクト(転送)します。route('posts.show', $post->id)で、リダイレクト先のURLを動的に生成しています。

変更点: 投稿詳細ページでコメント情報を効率的に取得

/app/Http/Controllers/PostController.php
1      */
2     public function show(string $id)
3     {
4-        $post = Post::findOrFail($id);
5+        $post = Post::with('comments.user')->findOrFail($id);
6         return view('posts.show', compact('post'));
7     }
8 
9

投稿詳細ページを表示するPostControllershowメソッドを修正しています。

  • Post::with('comments.user')->findOrFail($id);with()メソッドを使うことで、「Eager Loading(イーガーローディング)」という機能を利用しています。
    • これは、Post(投稿)の情報を取得するときに、それに関連するcomments(コメント)と、さらにそのコメントに関連するuser(コメント投稿者)の情報も「ついでに」まとめて取得する、という指示です。
    • これを使わないと、ビューでコメントを表示するたびに「このコメントの投稿者は誰?」という問い合わせがデータベースに発生し、コメント数が増えるとパフォーマンスが低下する原因(N+1問題)になります。Eager Loadingは、この問題を解決し、データベースへの問い合わせ回数を最小限に抑えるための重要なテクニックです。

変更点: 投稿詳細ページにコメント一覧とフォームを表示

/resources/views/posts/show.blade.php
1                 @endif
2         </div>
3     </div>
4+
5+    {{-- コメント一覧 --}}
6+    <h3>コメント一覧</h3>
7+    @if ($post->comments->isEmpty())
8+       <p>コメントはまだありません。</p>
9+    @else
10+        @foreach ($post->comments as $comment)
11+            <div class="card mb-2">
12+                <div class="card-body">
13+                    <p class="card-text">{{ $comment->content }}</p>
14+                    <p class="text-muted">投稿者: {{ $comment->user->name }} | 投稿日時: {{ $comment->created_at->format('Y-m-d H:i') }}</p>
15+                </div>
16+            </div>
17+        @endforeach
18+    @endif
19+
20+    {{-- コメントフォーム --}}
21+    <h3>コメントを投稿する</h3>
22+    @auth
23+        <form action="{{ route('comments.store', $post->id) }}" method="POST">
24+            @csrf
25+            <div class="mb-3">
26+                <label for="content" class="form-label">コメント内容</label>
27+                <textarea name="content" id="content" class="form-control" rows="3" required></textarea>
28+            </div>
29+            <button type="submit" class="btn btn-primary">コメント投稿</button>
30+        </form>
31+    @else
32+        <p>コメントを投稿するにはログインしてください。</p>
33+    @endauth
34 @endsection
35

このファイルは投稿詳細ページの見た目を定義するBladeテンプレートです。ここにコメント一覧とコメント投稿フォームを追加しています。

コメント一覧部分

  • @if ($post->comments->isEmpty()):コントローラーから渡された$postに紐づくコメントが存在するかどうかをisEmpty()で判定しています。
  • @foreach ($post->comments as $comment):コメントが存在する場合、foreachループで全てのコメントを一つずつ取り出し、表示します。
  • {{ $comment->content }}:コメントの本文を表示します。
  • {{ $comment->user->name }}:モデルで定義したリレーションを使って、コメントを投稿したユーザーの名前を簡単に取得して表示しています。

コメントフォーム部分

  • @auth / @else / @endauth:ログイン状態によって表示を切り替えています。ログインしているユーザー(@auth)には投稿フォームを表示し、ログインしていないユーザー(@else)にはログインを促すメッセージを表示します。
  • <form action="{{ route('comments.store', $post->id) }}" method="POST">:フォームの送信先URLを、ルーティングで定義した名前付きルートcomments.storeを使って生成しています。$post->idを渡すことで、どの投稿へのコメントかがURLに含まれます。method="POST"でPOSTリクエストとして送信することを指定しています。
  • @csrf:セキュリティ対策(CSRF攻撃を防ぐ)のために必須の記述です。Laravelが自動でトークンを埋め込み、不正なリクエストを防ぎます。
  • <textarea name="content" ...>:コメント本文を入力するためのテキストエリアです。name="content"がコントローラー側で$request->contentとして値を受け取るためのキーになります。

おわりに

お疲れ様でした。今回は、既存の掲示板アプリにコメント機能を実装する一連の流れを学びました。PostUserCommentの各モデルをhasManybelongsToで関連付けることで、投稿に紐づくコメントや投稿者情報を直感的に取得できる便利さを体験しました。また、CommentControllerでデータを保存する処理や、PostControllerwith()を使い効率的にデータを読み込むEager Loadingといった、実践的なテクニックも学びました。ここで身につけたリレーションの考え方やMVCの連携は、今後さらに複雑な機能を作る上での重要な土台となります。

関連コンテンツ