🖥️

Laravel で簡単な掲示板を作る

2021/04/06に公開

Laravel Breeze を使って簡単な掲示板を作ります。

  • スレッド・コメントの閲覧は誰でも可能
  • スレッド・コメントの作成はログインユーザーのみ

というような要件にします。
https://github.com/kztsy/laravel-bbs-app

この記事で扱うこと

  • 認証
  • スレッド作成・閲覧
  • コメント作成・閲覧・削除

Laravel Breeze とは

Laravel のスターターキットには Jetstream と Breeze があります。Jetstream が非常に高機能で、よりシンプルな認証機能を提供するのが Breeze です。Laravel の初心者は Breeze で要領を覚えてから Jetstream に進むことが推奨されています。

For those brand new to Laravel, we recommend learning the ropes with Laravel Breeze before graduating to Laravel Jetstream.

Starter Kits

環境構築

Windows に PHP 環境構築(XAMPP, Composer)

以前記事にしました。OS は Windows ですが、XAMPP を使用しているので他の OS でも似たような手順になると思います。

プロジェクト作成

composer create-project laravel/laravel laravel-bbs-app

プロジェクトが作成されたら、ディレクトリに移動しましょう。

cd laravel-bbs-app

Apache と MySQL の起動・DB 設定

データベースの設定をします。ここで設定をしておかないと、Breeze 導入の php artisan migrate でエラーになります。まずは XAMPP のコントロールパネルで Apache と MySQL を "Start" させます。

1.png

MySQL の Admin をクリックし、Admin パネルを開きましょう。新しいデータベースを作成します。

2.png

そして、Laravel プロジェクトの .env ファイルの DB_DATABASE を変更します。

// .env

- DB_DATABASE=laravel
+ DB_DATABASE=laravel_bbs_test

これで設定は完了です。

Breeze (+ Tailwind CSS) 導入

Laravel Breeze をインストールすると、勝手に Tailwind CSS が付いてきます。Breeze はログイン画面やユーザー登録画面、ナビゲーションなどのシンプルな UI を提供してくれます。そのスタイリングに Tailwind CSS が使用されています。

composer require laravel/breeze --dev

インストールが終わったら、以下のコマンドを入力します。

php artisan breeze:install

npm install

npm run dev

php artisan migrate

これで Breeze の導入は完了です。ブラウザで見てみましょう。

php artisan serve

3.ping

OS の設定がライトモードの場合は白色のデザインになっていると思います。

見づらいですが、右上に "Log in" と "Register" というリンクがあります。それぞれ Breeze がページを用意してくれています。

ログインページ

4.png

レジスターページ

5.png

Taro という名前で登録してみます。

6.png

このように、ダッシュボードページにリダイレクトされます。右上のドロップダウンからログアウトもできます。ログインした状態で進んでください。

config/app.php の編集

config/app.php を編集します。

- 'timezone' => 'UTC',
+ 'timezone' => 'Asia/Tokyo',

- 'locale' => 'en',
+ 'locale' => 'ja',

スレッド一覧ページの作成

ルートを追加します。

// routes/web.php

Route::get('/threads', function () {
    return view('threads.index');
})->name('threads');

テンプレートを作成します。

// resources/views/threads/index.blade.php

**<x-app-layout>
  <x-slot name="header">
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">
          {{ __('Threads') }}
      </h2>
  </x-slot>

  <div class="py-12">
      <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
              <div class="p-6 bg-white border-b border-gray-200">
                  Threads
              </div>
          </div>
      </div>
  </div>
</x-app-layout>**

dashboard.blade.php をコピーして、少し文字を変えただけです。http://127.0.0.1:8000/threads にアクセスすると、ちゃんと作成したページが表示されます。

ナビゲーションバーの修正

一度ログアウトして、再びスレッドページにアクセスすると、

Attempt to read property "name" on null

というエラーメッセージが表示されます。現在のレイアウトだとヘッダーの右端にユーザー名を表示しているので、表示するべきユーザーがいないとエラーになってしまいます。そこで、ナビゲーションバーを修正しましょう。

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

<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
    <!-- Primary Navigation Menu -->
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between h-16">
            <div class="flex">
                <!-- Logo -->
                <div class="flex-shrink-0 flex items-center">
                    <a href="{{ route('threads') }}">
                        <x-application-logo class="block h-10 w-auto fill-current text-gray-600" />
                    </a>
                </div>

                <!-- Navigation Links -->
                <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
                    <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                        {{ __('Dashboard') }}
                    </x-nav-link>
                </div>
            </div>

            <!-- Settings Dropdown -->
            <div class="hidden sm:flex sm:items-center sm:ml-6">
                @auth
                <x-dropdown align="right" width="48">
                    <x-slot name="trigger">
                        <button class="flex items-center text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out">
                            <div>{{ Auth::user()->name }}</div>

                            <div class="ml-1">
                                <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
                                    <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
                                </svg>
                            </div>
                        </button>
                    </x-slot>

                    <x-slot name="content">
                        <!-- Authentication -->
                        <form method="POST" action="{{ route('logout') }}">
                            @csrf

                            <x-dropdown-link :href="route('logout')" onclick="event.preventDefault();
                                                this.closest('form').submit();">
                                {{ __('Log out') }}
                            </x-dropdown-link>
                        </form>
                    </x-slot>
                </x-dropdown>
                @endauth

                @guest
                    <div class="flex gap-x-4">
                        <a href="{{ route('login') }}">{{ __('Log in') }}</a>
                        <a href="{{ route('register') }}">{{ __('Register') }}</a>
                    </div>
                @endguest
            </div>

            <!-- Hamburger -->
            <div class="-mr-2 flex items-center sm:hidden">
                <button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
                    <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
                        <path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
                        <path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                    </svg>
                </button>
            </div>
        </div>
    </div>

    <!-- Responsive Navigation Menu -->
    <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
        <div class="pt-2 pb-3 space-y-1">
            @auth
            <x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
                {{ __('Dashboard') }}
            </x-responsive-nav-link>
            @endauth
            @guest
            <x-responsive-nav-link :href="route('login')" :active="request()->routeIs('login')">
                {{ __('Log in') }}
            </x-responsive-nav-link>
            <x-responsive-nav-link :href="route('register')" :active="request()->routeIs('register')">
                {{ __('Register') }}
            </x-responsive-nav-link>
            @endguest
        </div>

        <!-- Responsive Settings Options -->
        @auth
        <div class="pb-1 border-t border-gray-200">
            <div class="mt-2 space-y-1">
                <form method="POST" action="{{ route('logout') }}">
                    @csrf
                    <x-responsive-nav-link :href="route('logout')" onclick="event.preventDefault();
                                        this.closest('form').submit();">
                        {{ __('Log out') }}
                    </x-responsive-nav-link>
                </form>
            </div>
        </div>
        @endauth
    </div>
</nav>

まず、左端のロゴのリンク先をスレッドページに変更しました。

次に、非ログイン時にはユーザー名とドロップダウンを非表示にし、代わりにログインページとレジスターページへのリンクを表示しました。また、ハンバーガーメニューを開いたときのプロフィール画像を消し、ログイン時と非ログイン時で表示するリンクを変えています。また、プロフィール画像を消した関係で、少しスタイルも修正しました。

7.png

このような画面になっていることを確認してください。これで、ログインしていないユーザーもスレッドを閲覧できます。

Thread コントローラーの作成

コントローラーを作成します。

php artisan make:controller ThreadController

スレッド一覧ページの表示をコントローラーに行わせてみましょう。

class ThreadController extends Controller
{

+    public function index()
+    {
+        return view('threads.index');
+    }

}
- Route::get('/threads', function () {
-     return view('threads.index');
- })->name('threads');

+Route::get('/threads', [ThreadController::class, 'index'])->name('threads');

このように、これ以降はコントローラーを介して処理を行っていきます。

Thread 作成ページの作成

テンプレートを作成します。

<x-app-layout>
  <x-slot name="header">
    <div class="flex items-center justify-between">
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">
        {{ __('New Thread') }}
      </h2>
    </div>
  </x-slot>

  <div class="py-12">
    <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
      <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
        <div class="p-6 bg-white border-b border-gray-200">
          <form action="{{ route('threads.create') }}" method="POST">
            @csrf
            <div>
              <label for="title">{{ __('Thread title') }}</label>
              <textarea name="title" id="title" cols="30" rows="2" class="w-full rounded-lg border-2 bg-gray-100 @error('title') border-red-500 @enderror"></textarea>

              @error('title')
              <div class="text-red-500 text-sm mt-2">
                {{ $message }}
              </div>
              @enderror
            </div>
            <div class="mt-4">
              <label for="body">{{ __('First comment') }}</label>
              <textarea name="body" id="body" cols="30" rows="4" class="w-full rounded-lg border-2 bg-gray-100 @error('comment') border-red-500 @enderror"></textarea>

              @error('body')
              <div class="text-red-500 text-sm mt-2">
                {{ $message }}
              </div>
              @enderror
            </div>
            <div class="mt-4">
              <button type="submit" class="bg-blue-500 rounded font-medium px-4 py-2 text-white">{{ __('Submit') }}</button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</x-app-layout>

ルートを追加します。

// routes/web.php

+ Route::get('/threads/create', [ThreadController::class, 'create'])->name('threads.create');
+ Route::post('/threads/create', [ThreadController::class, 'create']);

コントローラーに処理を追加します。

class ThreadController extends Controller
{

    public function index()
    {
        return view('threads.index');
    }

+    public function create()
+    {
+        return view('threads.create');
+    }

+    public function store(Request $request)
+    {
+        dd([
+            $request->title,
+            $request->body,
+	        ]);
+    }
}

アドレスバーから /threads/create にアクセスしてみましょう。

8.png

このような画面が表示されていれば OK です。また、適当にフォームに入力し、送信してみてください。

9.png

このような画面が表示されたら正常です。画像だと見にくいので文字にすると以下のようになります。

array:2 [0 => "This is thread title!"
  1 => "This is first comment!"
]

送信ボタンを押すと ThreadControllerstore メソッドが呼び出されます。現在は dd 関数でフォームの内容を出力しているので、このような挙動になります。

それではスレッド作成機能を実装していきましょう。

Thread モデルと Threads テーブルの作成

スレッドのモデルとテーブルを作成していきます。

php artisan make:model Thread -m

-m をつけてマイグレーションファイルも生成しています。

生成された Thread.php とマイグレーションファイルを以下のように修正してください。

// App/Models/Thread.php

class Thread extends Model
{
    use HasFactory;

+    protected $fillable = [
+        'title'
+    ];

}
// database/migrations/2021_04_04_082524_create_threads_table.php

public function up()
{
    Schema::create('threads', function (Blueprint $table) {
        $table->id();
+        $table->foreignId('user_id')->constrained()->onDelete('cascade');
+        $table->text('title');
        $table->timestamps();
    });
}

マイグレートを実行しましょう。

php artisan migrate

また、User モデルを以下のように修正します。

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    /**
     
			省略

    /**

+    public function threads()
+    {
+        return $this->hasMany(Thread::class);
+    }
}

hasMany の関係を指示しておくことで user()→threads()→create() のようにスレッドを作成できるようになります。

Comment モデルと Comments テーブルの作成

今回の掲示板では、スレッドの作成と同時に、1つ目のコメントを投稿するとします。また、各ユーザーはスレッドに対して自由にコメントできます。

php artisan make:model Comment -m

マイグレーションファイルを編集します。

// database/migrations/2021_04_04_085705_create_comments_table.php

public function up()
{
    Schema::create('comments', function (Blueprint $table) {
        $table->id();
+        $table->foreignId('user_id')->constrained()->onDelete('cascade');
+        $table->foreignId('thread_id')->constrained()->onDelete('cascade');
+        $table->text('body');
        $table->timestamps();
    });
}

マイグレートを実行しましょう。

php artisan migrate

そして、Comment モデルと User モデルと Thread モデルを以下のように修正します。

class Comment extends Model
{
    use HasFactory;

+    protected $fillable = [
+        'body',
+        'user_id'
+    ];

+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
}
class User extends Authenticatable
{
    use HasFactory, Notifiable;

    /**
     * 
     *   省略
     * 
     */
    
+    public function comments()
+    {
+        return $this->hasMany(Comment::class);
+    }
}
class Thread extends Model
{
    use HasFactory;

    protected $fillable = [
        'title'
    ];

+    public function comments()
+    {
+        return $this->hasMany(Comment::class);
+    }
}

belongsTo の関係を指示しておくことで、$comment->user->name のように、コメントの投稿者の名前を呼び出すことができます。

スレッド作成機能の実装

それでは store メソッドの中身を書きましょう。

ThreadControllerstore メソッドを以下のように変更してください。

public function store(Request $request)
{
    $request->validate([
        'title' => 'required|string|max:255',
        'body' => 'required|string|max:512',
    ]);

    DB::transaction(function () use ($request) {
        $thread = $request->user()->threads()->create([
            'title' => $request->title,
        ]);

        $thread->comments()->create([
            'body' => $request->body,
            'user_id' => $request->user()->id
        ]);
    });

    return back();
}

また、DB ファサードを利用しているため、ファイル内の上部に以下の記述を追加してください。

use Illuminate\Support\Facades\DB;

やっていることは3つです。

  • バリデーション
  • データの保存
  • リダイレクト

本来ならリダイレクト先はスレッドの詳細ページにしたいのですが、まだページを作成していないので、スレッド作成ページに戻しています。

また、ポイントはトランザクションです。もしトランザクションを使用しないと、スレッドの作成には成功したがコメントの作成には失敗、なんてことが起こりかねません。そのため、必ずトランザクションを行うようにしましょう。

これでスレッド作成機能が完成しました。実際にフォームを送信して、DB にスレッドとコメントが作成されていることを確認しましょう。phpMyAdmin から確認してもいいですし、TablePlus 等のツールもおすすめします。

TablePlus | Modern, Native Tool for Database Management.

スレッド詳細ページの作成

それではリダイレクト先のページを作成していきましょう。

テンプレートを作成しますが、左向き矢印の svg ファイルを使用している箇所があります。以下のページからアイコンをダウンロードし、public ディレクトリに置いてください。

矢印ボタン 左1

それではまずテンプレートを作成します。

// resources/views/threads/show.blade.php

<x-app-layout>
  <x-slot name="header">
    <div class="flex items-center gap-x-4">
      <a href="{{ route('threads') }}">
        <img src="/left-arrow.svg" width="30" height="30" alt=""> 
      </a>
      <div class="max-w-4xl">
        <h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $thread->title }}</h1>
      </div>
    </div>
  </x-slot>
  
  <div class="pt-6 max-w-4xl mx-auto grid bg-white mt-4">
    @if ($comments->count())
      @foreach ($comments as $comment)
      <x-comment-card :comment="$comment" />
      @endforeach
    @else
      No comments
    @endif
  </div>
</x-app-layout>

注意点としては、$thread $comments という変数を使用していることと、comment-card というコンポーネントを使用していることです。まずはコンポーネントを作っていきましょう。

php artisan make:component CommentCard

コマンドを実行すると CommentCard.phpcomment-card.blade.php が作成されます。

それぞれ以下のように編集します。

// app/View/components/CommentCard.php

<?php

namespace App\View\Components;

use Illuminate\View\Component;

class CommentCard extends Component
{
    public $comment;
    
    public function __construct($comment)
    {
        $this->comment = $comment;
    }

    /**
     * 
     *    省略
     * 
     */
}
// resources/views/components/comment-card.blade.php

@props(['comment' => $comment])

<div class="border-b-2 p-4">
    <span class="text-sm font-bold">{{ $comment->user->name }}</span>
    <span class="text-sm text-gray-600">{{ $comment->created_at->toDateTimeString() }}</span>
    
    <p>{{ $comment->body }}</p>
</div>

comment プロパティとコンストラクタでの処理を CommentCard.php に追加することで、コンポーネントを呼び出す際に comment プロパティを渡せるようになります。

それではコントローラーに処理を追加しましょう。

// app/Http/Controllers/ThreadController.php

public function show(Thread $thread)
    {
        $comments = $thread->comments()->with(['user'])->paginate(20);

        return view('threads.show', [
            'thread' => $thread,
            'comments' => $comments
        ]);
    }

このように記述することで、show.blade.php$thread$comments を使用できます。

次にルートを追加します。

// routes/web.php

+ Route::get('/threads/{thread}', [ThreadController::class, 'show'])->name('threads.show');

これで準備は整いました。最語に、スレッドの作成に成功したら、今作ったページにリダイレクトするように store メソッドを修正しましょう。

public function store(Request $request)
{
    $request->validate([
        'title' => 'required|string|max:255',
        'body' => 'required|string|max:512',
    ]);

    $thread =  DB::transaction(function () use ($request) {
        $thread = $request->user()->threads()->create([
            'title' => $request->title,
        ]);

        $thread->comments()->create([
            'body' => $request->body,
            'user_id' => $request->user()->id
        ]);

        return $thread;
    });

    return redirect()->route("threads.show", $thread);
}

修正した点は2つです。

  • トランザクションが新しいスレッドのデータを返すようになった
  • そのデータを用いて、詳細ページにリダイレクトした

それでは動作を確認しましょう。

10.png

送信します。

11.png

リダイレクトされ、スレッドとコメントが表示されました!

コメント投稿機能の実装

コメントを投稿できるようにしましょう。コントローラーを作成し、保存する処理を記述します。

php artisan make:controller CommentController

作成されたコントローラーの CommentController クラスに、以下のメソッドを追加します。

public function store(Thread $thread, Request $request)
{
    $request->validate([
        'body' => 'required|string|max:512'
    ]);

    $thread->comments()->create([
        'body' => $request->body,
        'user_id' => $request->user()->id
    ]);

    return back();
}

次に、web.php に以下のルートを追加します。

Route::post('/threads/{thread}/comments', [CommentController::class, 'store'])->name('comments.store');

ファイル上部でコントローラーを読み込むのも忘れないようにしてください。

use App\Http\Controllers\CommentController;

それではフォームを作成しましょう。

show.blade.php を以下のように変更します。

<x-app-layout>
  <x-slot name="header">
    <div class="flex items-center gap-x-4">
      <a href="{{ route('threads') }}">
        <img src="/left-arrow.svg" width="30" height="30" alt=""> 
      </a>
      <div class="max-w-4xl">
        <h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $thread->title }}</h1>
      </div>
    </div>
  </x-slot>
  
  <div class="pt-6 max-w-4xl mx-auto grid bg-white mt-4">
    @if ($comments->count())
      @foreach ($comments as $comment)
      <x-comment-card :comment="$comment" />
      @endforeach
    @else
      No comments
    @endif

+    <form action="{{ route('comments.store', $thread) }}" method="POST" class="m-4">
+      @csrf
+      <label for="body">{{ __('Comment') }}</label>
+      <textarea name="body" id="body" cols="30" rows="4" class="w-full rounded-lg border-2 bg-gray-100 @error('comment') border-red-500 @enderror"></textarea>
+      <div class="mt-4">
+        <button type="submit" class="bg-blue-500 rounded font-medium px-4 py-2 text-white">{{ __('Submit') }}</button>
+      </div>
+    </form>
  </div>
</x-app-layout>

12.png

実際にコメントを投稿してみましょう。

13.png

投稿できました!

スレッド一覧を表示する

再びスレッド一覧ページを開いてみましょう。スレッドを表示する処理を書いていないので、先程作成したスレッドが表示されていません。

ThreadControllerindex メソッドを修正しましょう。

public function index()
{
    $threads = Thread::latest()->paginate(20);

    return view('threads.index', [
        'threads' => $threads
    ]);
}

これで index.blade.php$threads を使用できます。

// resources/views/threads/index.blade.php

<x-app-layout>
  <x-slot name="header">
    <div class="flex items-center justify-between">
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">
        {{ __('Threads') }}
      </h2>
      <div>
        <a href="{{ route('threads.create') }}">{{ __('New Thread') }}</a>
      </div>
    </div>
  </x-slot>

  <div class="py-12 max-w-4xl mx-auto sm:px-6 lg:px-8 grid gap-y-2">
    @if ($threads->count())
        @foreach ($threads as $thread)
            <x-thread-card :thread="$thread" />
        @endforeach
    @else
        There is no thread.
    @endif
  </div>
</x-app-layout>

変更点は2つです。

  • スレッド作成ページへのリンクを表示した
  • スレッド一覧を表示した

ただ、 thread-card コンポーネントを作成していないため、まだ表示できません。作成しましょう。要領は comment-card コンポーネントを作成したときと同じです。

php artisan make:component ThreadCard

生成された ThreadCard.php ファイルの ThreadCard クラスにプロパティを追加し、

public $thread;

コンストラクタを修正します。

public function __construct(Thread $thread)
{
    $this->thread = $thread;
}

thread-card.blade.php は以下のようにします。

@props(['thread' => $thread])

<a href="{{ route('threads.show', $thread) }}" class="p-4 block grid bg-white sm:rounded-lg border-1 shadow-sm">
    <span>
        {{ $thread->title }}
    </span>
    <span class="text-gray-600 text-sm">
        {{ $thread->created_at->diffForHumans() }}
    </span>
</a>

スレッド一覧ページを見てみましょう。

14.png

スレッド作成ページへのリンクも、スレッド詳細ページへのリンクも、正常に動作しています。

ミドルウェアを使用する

現在の仕様には問題があります。ログインしていないユーザーもスレッドの作成やコメントの投稿を行うことができてしまいます。そこで、ミドルウェアを使用してユーザーの行動を制限します。

ThreadController クラスにコンストラクタを追加しましょう。

// app/Http/Controllers/ThreadController.php

// ThreadController の中に以下を追加
public function __construct()
{
    $this->middleware('auth')->only(['create', 'store']);
}

これだけで要件を満たすことができます。試しにログアウトしてみましょう。トップページにリダイレクトされるので、アドレスバーから /threads にアクセスしてみましょう。

15.png

一覧ページはちゃんと表示されます。それではスレッド作成ページにアクセスしてみましょう。アドレスバーから /threads/create にアクセスするか、"New Thread" のリンクをクリックしてください。

16.png

ログインページにリダイレクトされました!

コメントの投稿にも同様の制限をかけましょう。

// app/Http/Controllers/CommentController.php

// CommentController クラスの中に以下を追加
public function __construct()
{
    $this->middleware('auth')->only(['store']);
}

コメント削除機能の実装

練習として、削除機能も実装してみましょう。

  • ユーザーは自分のコメントを削除できる
  • 他人のコメントは削除できない

という機能にします。

CommentController に destroy メソッドを追加します。

// app/Http/Controllers/CommentController.php

// CommentController クラスの中に以下を追加
public function destroy(Comment $comment)
{
    $comment->delete();

    return back();
}

ルートを追加します。

// routes/web.php

Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');

comment-card コンポーネントにフォームを追加します。

@props(['comment' => $comment])

<div class="border-b-2 p-4">
    <span class="text-sm font-bold">{{ $comment->user->name }}</span>
    <span class="text-sm text-gray-600">{{ $comment->created_at->toDateTimeString() }}</span>
    
    <p>{{ $comment->body }}</p>

+    <form action="{{ route('comments.destroy', $comment) }}" method="post" class="mt-2">
+        @csrf
+        @method('DELETE')
+        <button type="submit" class="text-blue-500">{{ __('Delete') }}</button>
+    </form>
</div>

ログインして、スレッド詳細ページにアクセスしましょう。

17.png

Delete ボタンをクリックしてみましょう。

18.png

コメントが削除されました!

ポリシーの作成

コメントの削除に制限をかけましょう。先ほども、スレッドとコメントの作成に制限をかけましたね。しかし、今回の要件はそれと異なります。ログインしているかどうかだけでなく、コメントを投稿したユーザーと Delete リクエストを送ったユーザーが一致しているかどうかも確認しなければいけません。

そこで、ポリシーという機能を利用します。

php artisan make:policy CommentPolicy

CommentPolicy.php というファイルが生成されます。CommentPolicy クラスに、以下のメソッドを追加してください。

// app/Policies/CommentPolicy.php

// CommentPolicy クラスの中に以下を追加
public function delete(User $user, Comment $comment)
{
    return $user->id === $comment->user_id;
}

これで準備ができました。コントローラーの destroty メソッドを以下のように修正しましょう。

public function destroy(Comment $comment)
{
+    $this->authorize('delete', $comment);

    $comment->delete();

    return back();
}

一行追加しただけですが、これで削除制限が実装されました。

確認するために、ユーザーを追加します。一度ログアウトして、新しいユーザーを登録しましょう。そのままログインした状態で、スレッド詳細ページにアクセスしましょう。

19.png

Hanako という名前のユーザーを新たに登録しました。試しにいくつかコメントしてみましょう。

20.png

コメントできていますね。それでは、一番上にある Taro さんの投稿を削除してみましょう。先ほど作ったポリシーが適用されていれば、削除に失敗するはずです。

21.png

"THIS ACTION IS UNAUTHORIZED." と表示されましたね。ブラウザバックしてリロードしてみても、コメントが削除されていないことが確認できます!

それでは、削除が許可されている場合のみボタンが表示されるように修正しましょう。

@can('delete', $comment)
    <form action="{{ route('comments.destroy', $comment) }}" method="post" class="mt-2">
        @csrf
        @method('DELETE')
        <button type="submit" class="text-blue-500">{{ __('Delete') }}</button>
    </form>
@endcan

フォームを @can ディレクティブで囲みました。これで、許可されているユーザーにのみフォームが表示されます。

22.png

現在は Hanako でログインしているので、Taro のコメントには Delete ボタンが表示されていません!また、自分のコメントにはボタンが表示されています!

多言語対応

現在は、すべての単語を英語で表示しています。試しに、"Dashboard" という単語を日本語対応させてみましょう。

23.png

resources/lang/ja.json ファイルを作成し、以下のように記述します。

// resources/lang/ja.json

{
  "Dashboard": "ダッシュボード"
}

ダッシュボードページをリロードしましょう。

24.png

日本語表示になりましたね。これまで blade テンプレートファイルで、

{{ __('Dashboard') }}

のような書き方をしてきたかと思います。このように書いておけば、言語ごとに json ファイルを作成することで、多言語対応することができます。最初の方に config/app.php ファイルで locale を "ja" に設定したのを覚えているでしょうか。あそこで設定したため、日本語ファイルがあれば日本語を優先して表示してくれます。

まとめ

これで今回の記事は終わりにします。私は React や Next.js が好きなのであまり Laravel には慣れていませんが、学んでみると楽しいものですね。ORM に興味が湧いたので、TS で書けるという Prisma も近々触ってみようと思います。ここまで読んでくださった方、ありがとうございました。

Discussion