Laravel 5で確認画面付き問い合わせフォームを作る

  • 110
    いいね
  • 6
    コメント
この記事は最終更新日から1年以上が経過しています。

確認画面が好きなのは日本人だけなのか、調べてみてもLaravelで確認画面付きフォームを作る例が少ない感じに見えたので、自分がいつもやってる方法を紹介してみる。

前準備

Laravel 5 Repositoriesは使わなくても作れるけれど、使ったほうがコントローラーがスッキリするのでオススメ。

雛形作成

artisanで雛形を作成する。

php artisan make:controller --resource ContactController
php artisan make:request ContactRequest
php artisan make:entity Contact
php artisan make:migration create_contacts_table

Database Migration

マイグレーションをこんな感じに編集して

database/migrations/2015_12_01_000000_create_contacts_table.php
<?php

use Illuminate\Database\Migrations\Migration;

class CreateContactsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('contacts', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name');
            $table->string('email');
            $table->string('subject');
            $table->text('content');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::drop('contacts');
    }
}

artisanでテーブルを作成する

$ php artisan migrate

Mass Assignment

更新するカラムを$fillableに指定する。適切に設定しておかないとMassAssignmentExceptionを吐く。

app/Entities/Contact.php
<?php

namespace App\Entities;

use Illuminate\Database\Eloquent\Model;
use Prettus\Repository\Contracts\Transformable;
use Prettus\Repository\Traits\TransformableTrait;

class Contact extends Model implements Transformable
{
    use TransformableTrait;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'email',
        'subject',
        'content',
    ];
}

Routing

php artisan make:controller --resourceで作成されるRESTful Resource Controllersはこんな感じにRoutingを設定できる。

app/Http/routes.php
<?php

Route::resource('contacts', 'ContactController', ['only' => ['index', 'store']]);

今回はindexstoreしか使わないため、そのように指定している。

Form Request Validation

Form Request Validationを使うと、値の検証処理を切り出せるのでコントローラーがすっきりする。

お問い合わせ画面のRequest

app/Http/Requests/ContactRequest.php
<?php

namespace App\Http\Requests;

class ContactRequest extends Request
{
    use ConfirmRequestTrait;

    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'name'    => 'required',
            'email'   => 'required|email',
            'subject' => 'required',
            'content' => 'required',
        ];
    }

    /**
     * Set custom messages for validator errors.
     *
     * @return array
     */
    public function messages()
    {
        return [
            //
        ];
    }

    /**
     * Set custom attributes for validator errors.
     *
     * @return array
     */
    public function attributes()
    {
        return [
            'name'    => 'お名前',
            'email'   => 'メールアドレス',
            'subject' => '件名',
            'content' => '内容',
        ];
    }
}

use ConfirmRequestTraitを追加することで、確認画面付きのフォームにしている。authorizeメソッドの返り値をtrueにしておかないと、403エラーになるので注意。Form Request ValidationAuthorizing Form Requestsに詳しい説明が書いてある。

attributesでエラーメッセージの項目名を変更することができてとても便利なのだけれど、これを使ったコードをあげている人をあまり見ないのはなんでだろう?

確認画面の共通処理

After Validation Hookを使って確認画面用フラグの制御をしている。もっとスッキリ書く方法があるかも。

app/Http/Requests/ConfirmRequestTrait.php
<?php

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\Validator;

trait ConfirmRequestTrait
{
    /**
     * Set custom messages for validator errors.
     *
     * @param \Illuminate\Contracts\Validation\Factory $factory
     *
     * @return \Illuminate\Contracts\Validation\Validator
     */
    public function validator($factory)
    {
        // 値検証前の処理
        if (method_exists($this, 'beforeValidate')) {
            $this->beforeValidate();
        }

        // 確認画面用フラグのバリデーションを追加
        $rules = array_merge($this->rules(), [
            'confirming' => 'required|accepted',
        ]);

        $validator = $factory->make(
            $this->all(),
            $rules,
            $this->messages(),
            $this->attributes()
        );

        $validator->after(function ($validator) {
            $failed = $validator->failed();

            // 確認画面用フラグのバリデーションを除外
            unset($failed['confirming']);

            // 確認画面用フラグ以外にエラーが無い場合は確認画面を表示
            if (count($failed) === 0) {
                $this->merge(['confirming' => 'true']);
            }

            // 値検証後の処理
            if (method_exists($this, 'afterValidate')) {
                $this->afterValidate($validator);
            }
        });

        return $validator;
    }

    /**
     * Format the errors from the given Validator instance.
     *
     * @param  \Illuminate\Contracts\Validation\Validator  $validator
     *
     * @return array
     */
    protected function formatErrors(Validator $validator)
    {
        $errors = parent::formatErrors($validator);

        // 確認画面用フラグのエラーメッセージを削除
        unset($errors['confirming']);

        return $errors;
    }
}

Repository Service Provider

make:entityで生成されたContactRepositoryを登録する。

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

/**
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class RepositoryServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap the application services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }

    /**
     * Register the application services.
     *
     * @return void
     */
    public function register()
    {
        $repositories = [
            \App\Repositories\ContactRepository::class,
        ];

        foreach ($repositories as $repository) {
            $this->app->bind($repository, $repository.'Eloquent');
        }
    }
}

Blade Templates

共通部分

resources/views/layouts/master.blade.php
<!DOCTYPE html>
<html lang="{{ App::getLocale() }}">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>@yield('title') - App Name</title>
        @section('styles')
            <link rel="stylesheet" href="{{ elixir('css/app.css') }}">
        @show
        <!--[if lt IE 9]>
            <script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
            <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
        <![endif]-->
    </head>
    <body class="@yield('body-class')">
        <div class="container">
            @yield('content')
        </div>
        @section('scripts')
            <script src="{{ elixir('js/main.js') }}"></script>
        @show
    </body>
</html>

フォーム画面

resources/views/contacts/index.blade.php
@extends('layouts.master')

@section('title', 'お問い合わせ')

@section('content')
    @if(count($errors) > 0)
        <div class="alert alert-danger">
            <ul>
                @foreach($errors->all() as $error)
                    <li>{{ $error }}</li>
                @endforeach
            </ul>
        </div>
    @endif
    <div class="panel panel-default">
        <div class="panel-heading">
            <h1 class="panel-title">お問い合わせ</h1>
        </div>
        <div class="panel-body">
            {!! Form::open(['route' => ['contacts.store'], 'method' => 'post']) !!}
                <input type="hidden" name="confirming" value="{{ old('confirming', 'false') }}">
                <div class="form-group required {{ $errors->has('name') ? 'has-error' : '' }}">
                    <label class="control-label" for="name">お名前</label>
                    @if(old('confirming', 'false') === 'false')
                        <input type="text" class="form-control" name="name" value="{{ old('name') }}">
                    @else
                        <p class="form-control-static">{{ old('name') }}</p>
                        <input type="hidden" name="name" value="{{ old('name') }}">
                    @endif
                    @if($errors->has('name'))
                        <p class="help-block">{{ $errors->first('name') }}</p>
                    @endif
                </div>
                <div class="form-group required {{ $errors->has('email') ? 'has-error' : '' }}">
                    <label class="control-label" for="email">メールアドレス</label>
                    @if(old('confirming', 'false') === 'false')
                        <input type="email" class="form-control" name="email" value="{{ old('email') }}">
                    @else
                        <p class="form-control-static">{{ old('email') }}</p>
                        <input type="hidden" name="email" value="{{ old('email') }}">
                    @endif
                    @if($errors->has('email'))
                        <p class="help-block">{{ $errors->first('email') }}</p>
                    @endif
                </div>
                <div class="form-group required {{ $errors->has('subject') ? 'has-error' : '' }}">
                    <label class="control-label" for="subject">件名</label>
                    @if(old('confirming', 'false') === 'false')
                        <input type="text" class="form-control" name="subject" value="{{ old('subject') }}">
                    @else
                        <p class="form-control-static">{{ old('subject') }}</p>
                        <input type="hidden" name="subject" value="{{ old('subject') }}">
                    @endif
                    @if($errors->has('subject'))
                        <p class="help-block">{{ $errors->first('subject') }}</p>
                    @endif
                </div>
                <div class="form-group required {{ $errors->has('content') ? 'has-error' : '' }}">
                    <label class="control-label" for="content">内容</label>
                    @if(old('confirming', 'false') === 'false')
                        <textarea type="text" class="form-control" name="content" rows="10">{{
                            old('content')
                        }}</textarea>
                    @else
                        <p class="form-control-static">{!! nl2br(e(old('content'))) !!}</p>
                        <input type="hidden" name="content" value="{{ old('content') }}">
                    @endif
                    @if($errors->has('content'))
                        <p class="help-block">{{ $errors->first('content') }}</p>
                    @endif
                </div>
                <div class="form-group text-center">
                    @if(old('confirming', 'false') === 'false')
                        <button type="submit" class="btn btn-primary">確認</button>
                    @else
                        <button type="submit" name="action" value="post" class="btn btn-primary">送信</button>
                        <button type="submit" name="action" value="back" class="btn btn-default">戻る</button>
                    @endif
                </div>
            {!! Form::close() !!}
        </div>
    </div>
@endsection

<input type="hidden" name="confirming" value="{{ old('confirming', 'false') }}"> で確認画面の切替をする。

確認画面では「送信」と「戻る」で処理が別れるため、ボタンにnamevalueを指定して分岐させる。

この例では1つのテンプレートにまとめてしまったけれど、入力パーツ毎に細かいテンプレートを作って使い回せば綺麗に書けると思う。

送信ボタンダブルクリックでの二重送信防止

こんな感じのJavaScriptを仕込んでおくといいと思う。submitを単純に無効化してしまうと、戻るボタンが効かなくなるので注意。

$('form :submit').click(function (event) {
  var TIMEOUT = 10000;
  var target  = event.target;
  var $form   = $(target).closest('form');
  var $submit = $form.find(':submit');

  // clickしたsubmitの値をhiddenに保存
  var $hidden = $('<input/>', {
    type: 'hidden',
    name: target.name,
    value: target.value
  }).appendTo($form);

  event.preventDefault();
  event.stopPropagation();

  // 全てのsubmitを無効化
  $submit.prop('disabled', true);

  // 時間経過でsubmitの無効化を解除
  setTimeout(function () {
    $hidden.remove();
    $submit.prop('disabled', false);
  }, TIMEOUT);

  $form.submit();
});

投稿完了画面

resources/views/contacts/thanks.blade.php
@extends('layouts.master')

@section('title', 'お問い合わせ')

@section('content')
    <div class="panel panel-default">
        <div class="panel-heading">
            <h1 class="panel-title">お問い合わせ</h1>
        </div>
        <div class="panel-body">
            <div class="well well-lg text-center">お問い合わせありがとうございました</div>
        </div>
    </div>
@endsection

Controllers

storeメソッドの引数にContactRequestを指定するとForm Request Validationで値の検証ができるので、そのままデータベースに突っ込む。

ここでは完了ページでブラウザリロードされた時に二重送信されないようにトークンの再発行も行っている。

app/Http/Controllers/ContactController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\ContactRequest;
use App\Repositories\ContactRepository;

class ContactController extends Controller
{
    /**
     * @var ContactRepository
     */
    protected $contacts;

    /**
     * コンストラクタ
     *
     * @param ContactRepository $contacts
     */
    public function __construct(ContactRepository $contacts)
    {
        $this->contacts = $contacts;
    }

    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        return view('contacts.index');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param ContactRequest $request
     *
     * @return \Illuminate\Http\Response
     */
    public function store(ContactRequest $request)
    {
        // 確認画面で戻るボタンが押された場合
        if ($request->get('action') === 'back') {
            // 入力画面へ戻る
            return redirect()
                ->route('contacts.index')
                ->withInput($request->except(['action', 'confirming']));
        }

        // データベースに登録
        $this->contacts->create($request->all());

        // ブラウザリロード等での二重送信防止
        $request->session()->regenerateToken();

        // 完了画面を表示
        return view('contacts.thanks');
    }
}

ソースコード

https://github.com/ponko2/laravel-starter-kit/tree/contacts

画面サンプル

入力画面

入力画面.png

エラー画面

エラー画面.png

確認画面

確認画面.png

完了画面

完了画面.png