Laravelでレビュー機能(星&コメント)をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、この間「入荷したらメールでお知らせ」機能をつくるという記事を公開しましたが、記事の冒頭でも書いたとおりネットサーフィンしていて「あ❗これつくってみたい」となったことが始まりでした。

そして、同じくAmazonでもウィンドウショッピングしてたら、また「これもつくってみたい」と思う機能を発見しました。

それが・・・・・

レビュー機能(星とコメント)

です。

皆さんもよく知ってのとおりAmazonでお買い物をすると、その商品が良かったかどうかを星(5段階)と文章で投稿することができます。

今回はそのレビュー機能をLaravelでつくってみたいと思います。
ぜひ皆さんのお役に立てると嬉しいです😊✨

「レビュー機能って
考えた人すごいですよね👍」

開発環境: Laravel 7.x

前提として

Laravelにログイン機能がインストールされていて、テストユーザーが用意されている必要があります。

もしまだ準備されていない方は「Laravel6.x以降でログイン機能をインストールする方法」を参考にしてみてください。

データベースはこんなカンジです。

モデル&マイグレーションをつくる

まずデータベースまわりからやっていきます。
今回必要になるのは、次の2つです。

  • Product: 商品
  • ProductReview:商品のレビュー

つまり、ProductProductReviewは「1:多」の関係になります。

では、Productからです。
以下のコマンドを実行してください。

php artisan make:model Product -m

コマンドを実行したら、まずモデルに「1:多」のリレーションシップを追加します。

/app/Product.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    // リレーションシップ
    public function reviews() { // 👈 追加

        return $this->hasMany(\App\ProductReview::class, 'product_id', 'id');

    }
}

そして、マイグレーションは次のようにします。

/database/migrations/****_**_**_******_create_products_table.php

// 省略

public function up()
{
    Schema::create('products', function (Blueprint $table) {
        $table->id();
        $table->string('name')->comment('商品名'); // 👈 追加
        $table->timestamps();
    });
}

// 省略

次に、ProductReviewです。
同じく以下のコマンドを実行してください。

php artisan make:model ProductReview -m

こちらにも、usersテーブルとのリレーションシップをつくります。レビューはユーザーに所属しているのでbelongsToにします。

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class ProductReview extends Model
{
    // リレーションシップ
    public function user() { // 👈 追加

        return $this->belongsTo(\App\User::class, 'user_id', 'id')
            ->select('id', 'name');

    }
}

そして、マイグレーションもこのように変更してください。

/database/migrations/****_**_**_******_create_product_reviews_table.php

// 省略

public function up()
{
    Schema::create('product_reviews', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('product_id')->default(0)->comment('商品ID');
        $table->unsignedBigInteger('user_id')->default(0)->comment('ユーザーID');
        $table->integer('stars')->default(0)->comment('星');
        $table->text('comment')->comment('コメント');
        $table->timestamps();

        $table->foreign('product_id')->references('id')->on('products');
        $table->foreign('user_id')->references('id')->on('users');
        $table->unique(['product_id', 'user_id']);
    });
}

// 省略

では、この状態で一度マイグレーションしておきます。

php artisan migrate

データベースはこうなります。

テストデータをつくる

次にProductにはテストデータがほしいのでSeederをつくっていきます。
以下のコマンドを実行してください。

php artisan make:seed ProductsTableSeeder

中身は次のようにします。

/database/seeds/ProductsTableSeeder.php

// 省略

public function run()
{
    for($i = 1 ; $i <= 25 ; $i++) { // 👈 追加

        $product = new \App\Product();
        $product->name = 'テスト商品名 - '. $i;
        $product->save();

    }
}

// 省略

続いて、ProductsTableSeederLaravelに登録します。

/database/seeds/DatabaseSeeder.php

// 省略

public function run()
{
     //$this->call(UsersTableSeeder::class);
     $this->call(ProductsTableSeeder::class); // 👈 追加
}

// 省略

では、以下のコマンドでテストデータの追加をしましょう。

php artisan migrate:fresh --seed

データベースはこのようになります。

ルートをつくる

では次に、アクセスできるルートを登録します。

Route::get('product', 'ProductController@index');
Route::get('product/list', 'ProductController@list');
Route::post('product/review', 'ProductController@review');

1行目が直接ブラウザでアクセスするもので、2行目がAjaxを商品データの取得、そして3行目がレビューを投稿するルートになります。

コントローラーをつくる

では、ルートで設定したProductControllerを作っていきます。
以下のコマンドを実行してください。

php artisan make:controller ProductController

するとファイルが作成されるので、中身を以下のように変更します。

/app/Http/Controllers/ProductController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function index() {

        return view('product.index');

    }

    public function list() {

        return \App\Product::with('reviews.user')->get();

    }

    public function review(Request $request) {

        $result = false;

        // バリデーション
        $request->validate([
            'product_id' => [
                'required',
                'exists:products,id',
                function($attribute, $value, $fail) use($request) {

                    // ログインしてるかチェック
                    if(!auth()->check()) {

                        $fail('レビューするにはログインしてください。');
                        return;

                    }

                    // すでにレビュー投稿してるかチェック
                    $exists = \App\ProductReview::where('user_id', $request->user()->id)
                        ->where('product_id', $request->product_id)
                        ->exists();

                    if($exists) {

                        $fail('すでにレビューは投稿済みです。');
                        return;

                    }

                }
            ],
            'stars' => 'required|integer|min:1|max:5',
            'comment' => 'required'
        ]);

        $review = new \App\ProductReview();
        $review->product_id = $request->product_id;
        $review->user_id = $request->user()->id;
        $review->stars = $request->stars;
        $review->comment = $request->comment;
        $result = $review->save();
        return ['result' => $result];

    }
}

この中で重要なのがreview()メソッドです。

review()では、まず送信内容をバリデーションでチェックすることになるのですが、product_idに「独自バリデーション」が設定されていることに注目してください。

この独自バリデーションでは、以下の2つをチェックしています。

  • ログインしているか
  • レビュー投稿はまだ投稿していないか

つまり、ログインしていなければユーザーIDが取得できないので実行を拒否し、さらに同じ商品に重複してレビュー投稿させないようにしています。

※なお、独自バリデーションを別の場所でも使う場合は、今回のような書き方はせず、AppServiceProviderにしっかり独自バリデーションを定義する方がいいでしょう。(使い回しができますので👍)

ビューをつくる

では最後にビュー(HTML部分)です。

<html>
<head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-3">
    <h1 class="mb-3">レビュー(星&コメント)機能・サンプル</h1>
    <table class="table table-bordered mt-4">
        <thead class="bg-info text-white">
        <tr>
            <th>ID</th>
            <th>商品名</th>
            <th>レビュー</th>
        </tr>
        </thead>
        <tbody>
        <!-- 商品データをリスト表示・・・ ① -->
        <tr v-for="p in products">
            <td v-text="p.id"></td>
            <td v-text="p.name"></td>
            <td>
                <a href="#" type="button" v-if="!hasMyReview(p.reviews)" @click.prevent="openReviewForm(p.id)">
                    レビューを投稿
                </a>
                <!-- レビューをリスト表示 ・・・ ② -->
                <div v-for="r in p.reviews">
                    <div class="p-3 bg-light mt-2">
                        <span class="font-weight-bold" v-text="r.user.name"></span>
                        <v-star :value="r.stars"></v-star>
                        <div class="text-break mt-1" style="white-space:pre;" v-text="r.comment"></div>
                    </div>
                </div>
            </td>
        </tr>
        </tbody>
    </table>
    <!-- レビュー投稿のモーダル ・・・ ③ -->
    <div class="modal fade" id="review-modal">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="review-modalLabel">&#x1F4AC; レビューの投稿</h5>
                    <button type="button" class="close" data-dismiss="modal">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <div class="form-group">
                        <h4>スター</h4>
                        <div v-for="star in [5,4,3,2,1]">
                            <input v-model="reviewParams.stars" type="radio" :value="star">
                            <v-star :value="star"></v-star>
                        </div>
                    </div>
                    <div class="form-group">
                        <h4>コメント</h4>
                        <textarea class="form-control" v-model="reviewParams.comment"></textarea>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-link mr-2" data-dismiss="modal">閉じる</button>
                    <button type="button" class="btn btn-warning" @click="onSubmit">登録する</button>
                </div>
            </div>
        </div>
    </div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"></script>
<script>

    // 星を表示するコンポーネント ・・・ ④
    Vue.component('v-star', {
        props: ['value'],
        template: '<span><span v-for="number in parseInt(value)">&#x2B50;</span></span>'
    });

    new Vue({
        el: '#app',
        data: {
            userId: parseInt('{{ auth()->user()->id ?? -1 }}'), // ログイン・ユーザーID ・・・ ⑤
            products: [],
            reviewParams: {
                product_id: '',
                stars: '',
                comment: ''
            }
        },
        methods: {
            getProducts() {

                axios.get('/product/list')
                    .then(response => {

                        this.products = response.data;

                    });

            },
            openReviewForm(productId) {

                this.reviewParams = {
                    product_id: productId,
                    stars: '',
                    comment: ''
                };
                $('#review-modal').modal('show');

            },
            hasMyReview(reviews) { // すでに投稿済みかどうかのチェック ・・・ ⑥

                for(let review of reviews) {

                    if(this.userId === parseInt(review.user_id)) {

                        return true;

                    }

                }

                return false;

            },
            onSubmit(productId) {

                axios.post('/product/review', this.reviewParams)
                    .then(response => {

                        if(response.data.result === true) {

                            this.getProducts();
                            $('#review-modal').modal('hide');

                        }

                    })
                    .catch(error => {

                        // エラー処理
                        alert('入力内容が正しくありません。');
                        console.log(error);

                    });

            }
        },
        mounted() {

            this.getProducts();

        }
    });

</script>

</body>
</html>

この中でやっていることは次のとおりです。

①商品データをリスト表示

AjaxProductControllerlist()から取得した商品データをv-forでループさせながらリスト表示しています。

②レビューをリスト表示

商品データとレビューは「1:多」の関係にあるので、ここでもv-forでレビュのデータをループさせてリスト表示します。(つまり、ループの中にループがあることになります)

③レビュー投稿のモーダル

今回は「レビューを投稿」というリンクをクリックするとモーダル(ポップアップ)が表示され、そこで星と文章を入力する形にしますので、bootstrapが用意してくれているモーダル機能を使います。

④星を表示するコンポーネント

複数の場所で1〜5の星を表示をするのでVueコンポーネントを作っておくことにします。こうすることで重複する同じ記述をなくすことができます。

つまり、次のようにすると、

<v-star value="5"></v-star>

表示はこのようになります。

⑤ログイン・ユーザーID

ここは、次の⑥で使うことになるユーザーIDを取得するのですが、「PHP」の書き方に注目してください。

ここの流れとしては、

  • auth()->user()->idから値が取得出来るかチェック
  • 取得できるならその値を、
  • もし取得できないなら-1を表示する

となります。

さらにページ表示後の「JavaScript」ではその値をparseInt()で文字列→数値に変換してVueデータとして使うことになります。

例はこんなカンジです。

userId: parseInt('2') // 👈ユーザーID

⑥すでに投稿済みかどうかのチェック

ここでは、すでにそのログインユーザーがレビューを投稿済みかどうかをチェックし、もし投稿済みなら「レビューを投稿」というリンクは表示しないようになっています。

テストしてみる

では実際にテストしてみましょう❗
まず、ブラウザで「http://*****/product」にアクセスします。

では、太郎さんでログインして「テスト商品名 – 1」にレビューを投稿してみましょう。

今回はスターを5にし、コメントを入力して送信してみます。

すると、レビューが登録されて表示されました。

では、ログアウトして次は「次郎さん」でログインして表示してみます。
太郎さんのときは非表示になっていた「レビューを投稿」リンクが表示されました。

では、このまま次の入力で投稿してみましょう。

すると、太郎さんと次郎さんのレビュー内容が表示されました。

成功です😊✨

教材ソースコードをダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードすることができます。

Laravelでレビュー機能をつくる

※ただし、マイグレーションなどはご自身で実行していただく必要があります。

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はAmazonにあるようなレビュー機能をLaravelでつくってみました。

もしネットショップなどを構築したい場合は重要な機能になると思いますし、商品でなくてもフィードバックを得るには結構使えるんじゃないでしょうか。

ぜひ皆さんもやってみてくださいね。

ではでは〜❗

「僕は、Amazonとかのポイントは
貯めずに使い切る派です👍」

このエントリーをはてなブックマークに追加       follow us in feedly