Laravel でツイッターみたいなアンケート機能をつくる

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

【皆さんへのお礼】

今回でなんと400記事になります❗
ほぼ何も続かない私ですが、みなさんが記事を読みに来ていただけるおかげでここまで続けることができました。

今後もぜひお気軽にご訪問ください😊👍✨

さてさて、それほど頻繁ではないのですが私はTwitterを使っていて、ブログの記事が公開されると自動ツイートされるようにしています。

そんなツイッターですが、たまに流れてくるツイートで「えっ、こんなのあるんだ😳」と思うものがありました。

それが・・・・・・

アンケート機能

です。

Twitterを日頃から使っている方ならよくご存知だと思いますが、流れとしては次のようになっています。

  1. 選択肢がいくつか表示される
  2. 選択肢を選ぶ
  3. アンケート結果が表示される

そこで❗

今回はLaravelを使って、このアンケート機能を作ってみたいと思います。

ぜひ何かの参考になりましたら嬉しいです。😊✨

※ なお、今回は「400記事記念」ということで、比較的LaravelVueに慣れた人に向けてにコードを書いています。ご期待下さい。

「Twitter は情報収集に役立ちますね👍」

開発環境: Laravel 8.x

前提として

なお、今回のアンケートは重複回答を防止するために「ログイン必須」にします。そのため、ログイン機能がインストールされていることが前提です。

もしまだの方は以下を参考にしてログイン機能を先に準備しておいてください。

📝 参考記事: Laravel Breezeで「シンプルな」ログイン機能をインストール

データベースまわりをつくる

では、まずモデルマイグレーションでデータベースまわりをつくっていきましょう。

必要なテーブルは以下3つです。

  • 質問テーブル
  • 質問の選択肢テーブル
  • 回答テーブル

では、以下の3つのコマンドを実行してください。

php artisan make:model Question -m
php artisan make:model QuestionItem -m
php artisan make:model Answer -m

すると、モデルとマイグレーションのペアが3つ(全6ファイル)作成されるので、中身を以下のように変更します。

まずはマイグレーションです。

database/migrations/****_**_**_******_create_questions_table.php

public function up()
{
    Schema::create('questions', function (Blueprint $table) {
        $table->id();
        $table->string('title')->comment('タイトル');
        $table->dateTime('expired_at')->comment('有効期限');
        $table->timestamps();
    });
}

database/migrations/****_**_**_******_create_question_items_table.php

public function up()
{
    Schema::create('question_items', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('question_id')->comment('質問ID');
        $table->string('option')->comment('選択肢');
        $table->timestamps();

        $table->foreign('question_id')->references('id')->on('questions');
    });
}

database/migrations/****_**_**_******_create_answers_table.php

public function up()
{
    Schema::create('answers', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id')->comment('ユーザーID');
        $table->unsignedBigInteger('question_id')->comment('選択肢ID');
        $table->unsignedBigInteger('question_item_id')->comment('選択肢ID');
        $table->timestamps();

        $table->foreign('user_id')->references('id')->on('users');
        $table->foreign('question_id')->references('id')->on('questions');
        $table->foreign('question_item_id')->references('id')->on('question_items');
    });
}

そして、モデルです。

app/Models/Question.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Question extends Model
{
    use HasFactory;

    // Relationship
    public function items()
    {
        return $this->hasMany(QuestionItem::class, 'question_id', 'id');
    }
}

app/Models/QuestionItem.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class QuestionItem extends Model
{
    use HasFactory;

    // Relationship
    public function answers()
    {
        return $this->hasMany(Answer::class, 'question_item_id', 'id');
    }
}

※ なお、Answer.phpは初期のままでOKです。

テストデータをつくる

続いて、開発がしやすいようにテストデータをSeederでつくります。
以下のコマンドを実行してください。

php artisan make:seed QuestionSeeder

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

database/seeders/QuestionSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Question;
use App\Models\QuestionItem;
use Illuminate\Database\Seeder;

class QuestionSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $test_questions = [
            [
                'title' => '好きな動物は?',
                'options' => [
                    '犬',
                    '猫',
                    'パンダ',
                    'アライグマ',
                    'カモノハシ'
                ]
            ],
            [
                'title' => '行ってみたい国は?',
                'options' => [
                    'アメリカ',
                    'イギリス',
                    'フランス',
                    'ドイツ',
                    'カナダ'
                ]
            ]
        ];

        foreach ($test_questions as $test_question) {

            $question = new Question();
            $question->title = $test_question['title'];
            $question->expired_at = today()->addDays(rand(3, 7));
            $question->save();

            $test_options = $test_question['options'];

            foreach ($test_options as $test_option) {

                $question_item = new QuestionItem();
                $question_item->question_id = $question->id;
                $question_item->option = $test_option;
                $question_item->save();

            }

        }
    }
}

では、作成したQestionSeederLaravelへ登録します。

database/seeders/DatabaseSeeder.php

// 省略

public function run()
{
    // 省略

    $this->call(QuestionSeeder::class);
}

以下のコマンドを実行してデータベースを再構築しましょう。

php artisan migrate:fresh --seed

実際のテーブルはこうなりました。

バリデーションをつくる

次に、コントローラーをつくる前にバリデーション(入力チェック)の専用FormRequestをつくっておきましょう。

以下のコマンドを実行してください。

php artisan make:request QuestionRequest

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

app/Http/Requests/QuestionRequest.php

<?php

namespace App\Http\Requests;

use App\Models\Answer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class QuestionRequest extends FormRequest
{
    // 省略

    public function rules()
    {
        return [
            'question_id' => Rule::exists('question_items')->where(function ($query) { // question_id と question_item_id のペアが正しいかチェック

                return $query->where('id', $this->question_item_id);

            }),
            'question_item_id' => function ($attribute, $value, $fail) { // 投票済みかどうかチェック

                $user_id = auth()->id();
                $exists = Answer::where('question_id', $this->question_id)
                    ->where('user_id', $user_id)
                    ->exists();

                if ($exists === true) {

                    $fail('すでに投票済みです');

                }

            },
        ];
    }
}

なお、2つ目のバリデーションは直接functionを使って指定していますが、本来は専用バリデーション・ルールをつくる方がいいでしょう。

もし気になる方は、以下のページを参照してください。

📝 参考ページ: 独自バリデーション・ルールをつくる

コントローラーをつくる

では、次にコントローラーをつくります。
以下のコマンドを実行してください。

php artisan make:controller QuestionController

すると、コントローラーが作成されるので中身を次のようにしてください。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\QuestionRequest;
use App\Models\Answer;
use App\Models\Question;
use App\Models\QuestionItem;
use Illuminate\Http\Request;

class QuestionController extends Controller
{
    public function show(Question $question)
    {
        $now = now();

        if($question->expired_at < $now) { // すでに有効期限切れの場合

            abort(404);

        }

        $question->load('items');
        $user_id = auth()->id();
        $is_polled = Answer::where('question_id', $question->id)
            ->where('user_id', $user_id)
            ->exists();

        return view('question.index')->with([
            'question' => $question,
            'is_polled' => $is_polled
        ]);
    }

    public function result(Question $question)
    {
        $question_items = QuestionItem::withCount('answers')
            ->where('question_id', $question->id)
            ->orderBy('answers_count', 'desc') // 件数で並び替え
            ->get();
        $all_count = $question_items->sum('answers_count'); // すべての投票数

        return $question_items->map(function($question_item) use($all_count) {

            $percentage = ($all_count === 0)
                ? 0
                : round($question_item->answers_count / $all_count * 100, 1); // 結果のパーセント化

            return [
                'option' => $question_item->option,
                'percentage' => sprintf('%.1f', $percentage) // .0 が消えるのでフォーマット使用
            ];

        });
    }

    public function store(QuestionRequest $request)
    {
        $answer = new Answer();
        $answer->user_id = auth()->id();
        $answer->question_id = $request->question_id;
        $answer->question_item_id = $request->question_item_id;
        $result = $answer->save();

        return ['result' => $result];
    }
}

ビューをつくる

そして、コントローラー内でセットしたビューです。
以下のファイルを作成してください。

resources/views/question/index.blade.php

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-5">

    <div class="row" v-if="isStatusQuestion">
        <div class="col">
            <h3 class="mb-3" v-text="question.title"></h3>
            <div class="d-grid" v-for="item in question.items">
                <button
                    type="button"
                    class="btn btn-outline-info rounded-pill mb-2"
                    v-text="item.option"
                    @click="poll(item.id)"></button>
            </div>
        </div>
        <div class="col"></div>
        <div class="col"></div>
    </div>

    <div class="row" v-if="isStatusResult">
        <div class="col">
            <h3 class="mb-3" v-text="question.title"></h3>
            <div v-for="result in questionResults">
                <v-question-result
                    :option="result.option"
                    :percentage="result.percentage"></v-question-result>
                <br>
            </div>
        </div>
        <div class="col"></div>
        <div class="col"></div>
    </div>


</div>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/vue@3.1.1/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script>

    const questionResultComponent = {
        props: {
            option: {
                type: String,
                default: ''
            },
            percentage: {
                type: Number,
                default: 0
            }
        },
        computed: {
            barStyles() {

                return {
                    backgroundColor: '#b2e9f8',
                    width: `${this.percentage}%`
                };

            }
        },
        template: `
            <div class="position-relative py-1">
                <div class="position-absolute" :style="barStyles">&#8203;</div>
                <div class="position-absolute ps-1" v-text="option"></div>
                <div class="position-absolute" style="right:0;">
                    <span v-text="percentage"></span> %
                </div>
            </div>
        `
    };

    Vue.createApp({
        data(){
            return {
                status: 'question', // `question` or `result`
                question: @json($question),
                isPolled: @json($is_polled),
                questionResults: {}
            }
        },
        methods: {
            poll(questionItemId) {

                const url = '{{ route('question.store') }}';
                const params = {
                    question_id: this.question.id,
                    question_item_id: questionItemId
                };

                axios.post(url, params)
                    .then(response => {

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

                            this.getResult();

                        }

                    })
                    .catch(error => {

                        alert('バリデーション・エラー');
                        console.log(errors.response.data);

                        // TODO: ここでエラー処理

                    });

            },
            getResult() {

                const url = '{{ route('question.result') }}/'+ this.question.id;

                axios.get(url)
                    .then(response => {

                        this.status = 'result'; // 表示を切り替え
                        this.questionResults = response.data;

                    });

            }
        },
        computed: {
            isStatusQuestion(){

                return (this.status === 'question');

            },
            isStatusResult(){

                return (this.status === 'result');

            }
        },
        mounted() {

            if(this.isPolled === true) {

                this.getResult();

            }

        }
    })
    .component('v-question-result', questionResultComponent)
    .mount('#app');

</script>
</body>
</html>

ちょっとここは複雑なのですが、重要な点は、独自のコンポーネントquestionResultComponentをつくっているところです。

ルートをつくる

では、最後にルートです。
今回はログインが必須なコンテンツですので、中身は次ようになります。

routes/web.php

// 省略

Route::middleware('auth')->group(function() {

    Route::get('question/{question}', [QuestionController::class, 'show'])->name('question.show');
    Route::get('question/result/{question?}', [QuestionController::class, 'result'])->name('question.result');
    Route::post('question', [QuestionController::class, 'store'])->name('question.store');

});

これで作業は終了です。
お疲れ様でした😊✨

テストしてみる

今回は400回記念ですので、テストしている動画をYouTubeに用意しました。
ぜひご覧ください。

↓↓↓

テスト動画

企業様へのご提案

アンケート機能を利用すると、お客さんからの声だけでなく社内の意見や方向性を知るきっかけにすることができるかと思います。

もし、現在使用されているシステムへアンケート機能をつけたいとお考えでしたらぜひお気軽にご連絡ください。

どうぞよろしくお願いいたします。m(_ _)m

おわりに

ということで、今回はいつもと違い少しだけ本格的(複雑?)なコードをご紹介しましたが、いかがだったでしょうか。

正直なところプログラム初学者が見たら「うーん・・・プログラム嫌い❗」なんてことになるかもしれないので、少し悩んだのですが、なにせ今回は「400記事記念」ということでゴリ押ししてみました(笑)

とはいえ、コードは長いですが注目はそこではなく、「結局短いコードの集合体なんだな」と感じていただけたら嬉しいです。

長年コードを書いている私でも1行目から最後の行までをスラスラ書いているわけではなく、あっちに行ったりこっちに行ったり、はたまた長すぎるから分割したりという作業をくりかえしているに過ぎません。

つまり、根本を見つけたらある程度どころか、ながーーーーーーーーーーーーーーーい(京都銀行のCM知ってます?)コードでもそれほど怖くはなくなってくると思います。

ぜひいいきっかけになりましたら嬉しいです。

ではでは〜❗

「当初はブログなんて
すぐやめるんだろうな
って思ってました(笑)」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ