2020-02-11に更新

質問に回答する機能を作る

読了目安:12分

質問機能は作成済みですので、同様にメインとなる機能である回答機能を作成していきます。

回答テーブルを作成する

とりあえず回答用のテーブルのマイグレーションを作成しておきます。

php artisan make:migration create_answers --create answers

中身はこんな感じです。

class CreateAnswers extends Migration
{
    public function up()
    {
        Schema::create('answers', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->bigInteger('question_id')->unsigned()->index();
            $table->bigInteger('user_id')->unsigned()->index();
            $table->text('body');
            $table->timestamps();

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

    public function down()
    {
        Schema::dropIfExists('answers');
    }
}

どの質問への回答か、どのユーザーが回答したか、回答の内容のカラムを追加しています。user_idは実際には不要で、question_idでquestionsを検索し、questionsのreceived_user_idでどのユーザが回答したかは分かります。

ただし、回答者用の自分の回答一覧ページを作る際に、わざわざ上記のような形でJOINして検索を行わなくてはならないため負荷が増える可能性があります。具体的には下記のようなSQLです。個人的にはJOIN先のみでWHEREするのは非常に微妙だと思っています。

SELECT answers.*
FROM answers
LEFT JOIN questions ON questions.id = answers.question_id
WHERE questions.received_user_id = {{ユーザーID}}

そのためリレーショナルデータベース的には不要ですが、検索を軽量化するための冗長化としてuser_idも入れています。これで下記のシンプルなSQLで取得が可能となります。むちゃくちゃ軽いです。

SELECT * FROM answers WHERE user_id = {{ユーザーID}}

何にしろマイグレーションを実行しておきます。

php artisan migrate

回答ページを作成

本来であれば自分に来た質問一覧ページからリンクをクリックして回答ページへ行く流れですが、とりあえず回答処理を作ってしまいたいので直接回答ページから作っていきます。

回答するページのURLはquestions/{id}/receivedとします。とりあえずルーティングを設定します。authミドルウェアの中に入れましょう。

Route::group(['middleware' => ['auth']], function () {
    Route::get('questions/{id}/received', 'QuestionController@received');
    Route::resource('questions', 'QuestionController')->only(['store']);
});

回答フォーム用の処理を入れていきます。QuestionControllerに下記のようなアクションを入れます。

    public function received($questionId)
    {
        $question = Question::where('id', $questionId)
            ->where('received_user_id', Auth::id())
            ->firstOrFail();

        return view('question.received', compact('question'));
    }

取得する質問データが自分に宛てられたものかどうかもチェックしており、違う場合は本来存在しないURLですのでfirstOrFailで404にしています。このあたりはチームでやっているのであれば各プロジェクトのルールによって適宜適切なエラー処理のルールで対応していくと良いと思います。

続いて表示するためのテンプレートをresources/views/question/received.blade.phpとして作成します。

@extends('layouts.app')

@section('content')
<section class="text-center pt-4">
  <h1 style="font-size: 1.5rem">回答する</h1>
  <div class="card">
    <div class="card-body">
      {{ $question->body }}
    </div>
  </div>
</section>
@endsection

とりあえず回答時に見るための質問表示です。Bootstrapのカードで適当に表示しています。

Cards · Bootstrap

http://localhost:8000/questions/1/received にアクセスすると下記が表示されます。ホスト名、ポート、ID(URLの例で1になっているところ)は適宜自分の環境に合わせてください。認証ガードをしていますのでうまく表示されない場合はログインしておいてください。表示が崩れる場合はyarn hotの実行も必要です。

続いてcard(の閉じタグ)の下にフォームを入れていきます。質問フォームとほとんど同じです。

  <div class="card mb-4">
    <div class="card-body">
      {{ $question->body }}
    </div>
  </div>

  {!! Form::open(['url' => url('answers')]) !!}
  {!! Form::hidden('question_id', $question->id) !!}
  <div class="form-group">
    {!! Form::textarea('body', null, [
      'class' => 'form-control',
      'placeholder' => '回答する',
      'required' => true,
    ]) !!}
  </div>
  {!! Form::Submit('回答する', ['class' => 'btn btn-primary']) !!}
  {!! Form::close() !!}

質問とくっついてしまうので質問のclassにmb-4でmargin-bottomも入れています。

回答を登録する

formもできたので回答を登録できるようにします。まずは回答処理用のコントローラを作成します。

php artisan make:controller AnswerController

ついでにモデルも使うと思うので作っておきます

php artisan make:model Models/Answer

Questionの時と同様、guardedを設定しておきます。

class Answer extends Model
{
    protected $guarded = ['id'];
}

回答データを保存する際、質問データの子として保存しますので、その関係を質問のモデルにも定義しておきます。Models/Question.phpをに下記のような定義を追加します。

    public function answer()
    {
        return $this->hasOne(Answer::class);
    }

これはそのままの通り、Question has one answer、つまり質問データは一つの回答データを持つ、という定義でリレーションといいます。このようにリレーションを定義しておくことでデータを保存する際や取得する時に簡単に処理を作成することができるようになっています。詳しくは下記の公式マニュアルにも書かれており、他にも様々な種類のリレーションの定義ができるようになっています。

Eloquent:リレーション 6.0 Laravel

実際に回答登録処理を作成します。まずは回答用のルーティングを作成します。answersというところです。なんとなくですが、問題がでるところでなければアルファベット順で書くと増えた時に見やすいと思います。

Route::group(['middleware' => ['auth']], function () {
    Route::resource('answers', 'AnswerController')->only(['store']);
    Route::get('questions/{id}/received', 'QuestionController@received');
    Route::resource('questions', 'QuestionController')->only(['store']);
});

resourceとは

ところでこのresourceとは何かを一旦解説します。resourceというのは、基本的な処理のためのアクションをまとめてルーティングに登録できる機能です。Laravelでは具体的には下記になっています。

メソッド アクション 意味
GET index 一覧
GET create 新規登録ページ
POST store 新規登録処理
GET edit 編集ページ
PUT, PATCH update 更新処理
DELETE destroy 削除

アクションというのはコントローラのメソッド名ですので分かりやすいと思います。今回の例でいうと回答の登録処理にはstoreというメソッドを追加します。

メソッドというのはちょっと最初はわかりにくいかもしれませんが、アプリケーションにどのような処理をするか、というのを伝えるためには実はURLだけでなく、URLとメソッドの組み合わせが必要になっています。

普段ブラウザでWebにアクセスする場合は意識していないと思いますが、単にURLにアクセスしているだけではなく、実はGETメソッドでそのURLにアクセスを行っています。そしてformで何かを登録する際も、その登録先のURLにただアクセスしているわけではなく、POST, PUT, PATCH, DELETEのどれかでアクセスを行っています。

つまり例えば同じ/questionsというURLでも、ブラウザで単にアクセスした場合はGETのアクセスになるので一覧ページが表示され、formでPOSTした場合はデータが登録される、ということになります。とりあえず今のところは深く考える必要はなく、上記のテーブルのメソッドとアクションの組み合わせでコントローラに処理を書けば良い、ということになります。そして、そもそもRoute::resourceというメソッドがそれを勝手にいい感じに処理してくれるため、とりあえずは深く考える必要はありません。

回答ページであるquestions/{id}/receivedのように、resourceの形ではなく独自で定義したい場合にRoute::getRoute::post等を利用することができる、という感じです。

マニュアルでもこのあたりは書かれています。

リソースコントローラ

回答処理を作成

さて、では実際にAnswerControllerに回答の登録処理を作成します。

namespace App\Http\Controllers;

use App\Models\Answer;
use App\Models\Question;
use Auth;
use Illuminate\Http\Request;

class AnswerController extends Controller
{
    public function store(Request $request)
    {
        $question = Question::where('id', $request->input('question_id'))
            ->where('received_user_id', Auth::id())
            ->firstOrFail();

        $answer = new Answer($request->all());
        $answer->user_id = Auth::id();
        $question->answer()->save($answer);

        session()->flash('success', '回答しました。');
        return redirect("questions/{$question->id}/received");
    }
}

最初は回答フォームの処理と同様、自分に来た質問かどうかをチェックしています。

次に回答データを作成しています。$question->answer()->save($answer);という処理により、その$questionの配下になるように回答を保存してくれます。この保存の仕方だとわざわざ$answer->question_idを自分で代入しなくても勝手に入れて保存してくれます。先程モデルに定義したリレーションによってこれができるようになっています。

これで実際に回答フォームから回答を登録すると正常にデータが入ります。

回答ページに回答済みの内容を表示する

既に回答した質問に回答フォームを表示してしまうとおかしいため、回答済みの場合は回答データを表示するようにしておきます。回答ページのテンプレートの回答フォームを条件分岐で表示を切り替えます。ちょこちょこ見出しも変えたりしたためまるごと下記に書きます。

<section class="text-center pt-4">
  <h1 style="font-size: 1.5rem">届いた質問</h1>
  <div class="card mb-4">
    <div class="card-body">
      {{ $question->body }}
    </div>
  </div>

  <h2 style="font-size: 1.2rem">回答</h2>

  @if ($question->answer)

  <div class="card">
    <div class="card-body">
      {{ $question->answer->body }}
    </div>
  </div>

  @else

  {!! Form::open(['url' => url('answers')]) !!}
  {!! Form::hidden('question_id', $question->id) !!}
  <div class="form-group">
    {!! Form::textarea('body', null, [
      'class' => 'form-control',
      'placeholder' => '回答する',
      'required' => true,
    ]) !!}
  </div>
  {!! Form::Submit('回答する', ['class' => 'btn btn-primary']) !!}
  {!! Form::close() !!}

  @endif
</section>

リレーションを定義しているため、$question->answerという形で簡単に回答が取れます。回答データがあれば@ifの方が実行されて回答が表示されます。回答がなければnullになりますので、@elseの方が実行され、回答フォームが表示されます。回答データを直接phpMyAdmin等で削除してみたり再度回答してみたりすると切り替わるのが分かると思います。

これで一旦回答機能の作成は完了です。

微調整

少し気になるところが出てきたため一旦微調整しておきます。

ナビを調整

ナビがcontainerの中に入って短く切れている感じが変だったので、containerから出してbodyの最初に配置しました。また、ナビの中にcontainerをいれて全体的な横幅を揃えました。

    <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
      <div class="container" style="max-width: 600px">
      :
      :
      </div>
    </nav>

    <div class="container" style="max-width: 600px">
      @if (session('success'))
      <div class="alert alert-success" role="alert">
        {{ session('success') }}
      </div>
      @endif
      @yield('content')
    </div>

最初の余白を調整

質問ページも回答ページも最初の要素にpt-4をいれて、ナビとくっつきすぎるのを防いでいました。しかし全てのページでこれをやるのは微妙すぎるため、ナビのmargin-bottomをつけて共通化するようにしました。

具体的には各ページのpt-4を削除し、ナビにmb-4を入れました。

まとめ

質問形サービスの根幹となる機能がまた一つできました。まだ細かいところも大きいところもちょこちょこ残っていますので、引き続き進めていきます。次回は片手で小銭を最大何枚持てるかを検証していきたいと思います。

ツイッターでシェア
みんなに共有、忘れないようにメモ

view_list [連載] Laravelで質問箱みたいなサービスを作る
第3回 質問できるようにする
第4回 Bootstrapでベースデザインを整える
第5回 質問に回答する機能を作る
第6回 Twitterシェア用のOGPを作成する
第7回 受け取った質問一覧ページを作る

だら@Crieit開発者

Crieitの開発者です。 Webエンジニアです(在宅)。大体10年ちょい。 記事でわかりにくいところがあればDMで質問していただくか、案件発注してください。 業務依頼、同業種の方からのコンタクトなどお気軽にご連絡ください。 業務経験有:PHP, MySQL, Laravel, React, Flutter, Vue.js, Node, RoR 趣味:Elixir, Phoenix, Nuxt, Express, GCP, AWS等色々 PHPフレームワークちいたんの作者

Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

有料記事を販売できるようになりました!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?

コメント