CINC Tech Blog

株式会社CINCのエンジニアチームが日々習得した技術やTipsを公開するブログです

[入門編] LaravelでWebアプリ開発

開発部のH.Kです。
以前に弊社開発部のFが、Laravelを使用して「アルゴノート」の管理画面を開発しましたが、 今後、「Keywordmap」等のプロダクトにもLaravelを採用する可能性があるため、 laravelでの基本的な開発の流れを理解するために、簡単なWEBアプリケーションを作成してみました。
Laravelでの開発は、イノベーションプロジェクト(※)でも進めています。
※.弊社では週3時間を割り当てて、革新をもたらすシステム/機能の開発をメンバー主体で進めております。

アプリケーションの概要

要件

今回は、以下の要件を満たすtodoアプリケーションを作成します。 1. タスク名とタスク内容/期限を指定して、タスクを登録できる。(ステータスは「未着手」として登録される) 2. 登録したタスクを一覧形式で閲覧できる。 3. タスクの情報(タスク名、タスク内容、ステータス、期限)を編集できる。 4. タスクを削除できる。

画面イメージ

今回作成する画面イメージは以下のとおりです。

f:id:coreinc:20190319180624j:plain
タスク登録
f:id:coreinc:20190319180751j:plain
タスク一覧
f:id:coreinc:20190319180730j:plain
タスク編集

テーブル構成

テーブルは以下の構成とします。

task テーブル

No. カラム名 データ型 必須 主キー デフォルト
1 タスクID INT 1
2 タスク名 VARCHAR(50)
3 タスク内容 VARCHAR(500)
4 ステータスID CHAR(2)
5 期限 DATE
6 作成日時 TIMESTAMP
7 更新日時 TIMESTAMP

status テーブル

No. カラム名 データ型 必須 主キー デフォルト
1 ステータスID CHAR(2) 1
2 ステータス名 VARCHAR(10)
3 作成日時 TIMESTAMP
4 更新日時 TIMESTAMP

アプリケーション構成

以下の構成でアプリケーションを実装します。 レイヤードアーキテクチャを意識してサービスクラスを作成し、コントローラからビジネスロジックを分離しています。 また、バリデーションもフォームリクエストで実装します。

f:id:coreinc:20190319180801j:plain

URL一覧

このアプリでは以下のURLを使用します。

No. URL メソッド 処理概要
1 /tasks GET タスクの一覧を表示します
2 /task/create GET タスクの登録画面を表示します
3 /task/create POST タスクを登録します
4 /task/{タスクID}/edit GET タスクの編集画面を表示します
5 /task/{タスクID}/edit PUT タスクを更新します
6 /task/{タスクID}/delete GET タスクを削除します

準備

環境構築

Laravelでの開発の流れを理解することが目的なので、開発環境の構築手順は省略します。
構築に関する詳細は、Laravelの公式サイトをご参照ください。

ここで使用するソフトウェア/ライブラリのバージョンを以下に記載します。

ソフトウェア/ライブラリ バージョン
PHP 7.2.11
Laravel 5.7.25
MySQL 5.7.24

Laravelプロジェクトの作成

  1. 下記コマンドを実行することで、カレントディレクトリにLaravelプロジェクトを作成します。
    ※.プロジェクト名として「todo」を指定しています。これにより「todo」ディレクトリが作成されます。
composer create-project laravel/laravel todo --prefer-dist "5.7.*"

※.以降では特に指定がない限り、「todo」ディレクトリをカレントディレクトリとして記載しています。

  1. 今回は、ヘルパー関数を使用してビューを記述します。 そのため、下記コマンドを実行してlaravelcollective/htmlパッケージをインストールします。
composer require "laravelcollective/html":"^5.7.0"

マイグレーション

  1. 下記コマンドを実行することで、マイグレーションファイルを作成します。
php artisan make:migration create_status_table --create=status
php artisan make:migration create_task_table --create=task

マイグレーションファイルは、database/migrationsディレクトリに下記フォーマットで作成されます。

[yyyy]_[MM]_[dd]_[hhmmss]_create_status_table.php
[yyyy]_[MM]_[dd]_[hhmmss]_create_task_table.php
  1. 「status」および「task」テーブルを作成するために、マイグレーションファイルを変更します。

database/migrations/[yyyy]_[MM]_[dd]_[hhmmss]_create_status_table.php

class CreateStatusTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('status', function (Blueprint $table) {
            $table->char('status_id', 2);
            $table->string('status_name', 10);
            $table->timestamps();
            $table->primary('status_id');
        });
    }

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

database/migrations/[yyyy]_[MM]_[dd]_[hhmmss]_create_task_table.php

class CreateTaskTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('task', function (Blueprint $table) {
            $table->increments('task_id');
            $table->string('task_name', 50);
            $table->string('task_contents', 500)->nullable();
            $table->char('status_id', 2);
            $table->date('due_date')->nullable();
            $table->timestamps();
            $table->foreign('status_id')->references('status_id')->on('status');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('task');
    }
}
  1. 下記コマンドを実行し、DBにテーブルを作成します。
php artisan migrate

※.テーブルを作成する際は、マイグレーションファイルの作成順(ファイル名の昇順)に処理が実行されます。
  テーブル作成時の実行順序を変えたい場合は、ファイル名の日時部分を書き換えて下さい。

事前データ作成

  1. 下記コマンドを実行することで、シーダーファイルを作成します。
php artisan make:seeder StatusTableSeeder
  1. 初期データを登録するために、シーダーファイルを変更します。
class StatusTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $this->insertStatus('01', '未着手');
        $this->insertStatus('02', '対応中');
        $this->insertStatus('03', '完了');
        $this->insertStatus('04', 'キャンセル');
    }

    /**
     * 
     * @param String $statusId
     * @param String $statusName
     */
    private function insertStatus(String $statusId, String $statusName)
    {
        $now = Carbon::now();
        $status = [
            'status_id'   => $statusId,
            'status_name' => $statusName,
            'created_at'  => $now,
            'updated_at'  => $now
        ];
        DB::table('status')->insert($status);
    }
}
  1. 下記コマンドを実行し、「status」テーブルに初期データを登録します。
php artisan db:seed --class=StatusTableSeeder

これにより、「status」テーブルには以下のデータが登録されます。

ステータスID ステータス名
01 未着手
02 対応中
03 完了
04 キャンセル

実装

Model

  1. 下記コマンドを実行することで、各モデルクラスを作成します。
php artisan make:model Status
php artisan make:model Task

モデルクラスは、appディレクトリに作成されます。

  1. 作成したモデルクラスを変更します。

app/Status.php

class Status extends Model
{
    protected $table = 'status'; // テーブル名を指定
}

app/Task.php

class Task extends Model
{
    protected $table = 'task'; // テーブル名を指定

    protected $primaryKey = 'task_id'; // 主キーのカラム名を指定

    public function status()
    {
        // belongsToメソッドでStatusモデルにアクセスするリレーションを定義
        return $this->belongsTo('\App\Status', 'status_id', 'status_id');
    }
}

モデルは、LaravelのORMである「Eloquent」によってテーブルと関連付けられます。 「Eloquent」にはいくつかの規約があるため、テーブル名等の指定を省略することができますが、 規約を適用したくない場合は、明示的に指定することもできます。 テーブル名については、以下の規約があるため、ここでは上記のように明示的に指定しています。

テーブル名 = クラス名の複数形をスネークケースに変換した文字列

※.テーブル名以外の規約については、Laravelの公式サイトをご参照下さい。

Service

  1. ビジネスロジックを実装するServiceクラスを作成します。
    Serviceクラスはartisanコマンドで作成できないため、 「app」ディレクトリ直下に「Services」ディレクトリを作成して、 app/Services/TaskService.phpファイルを作成します。
    作成したサービスクラスには下記内容を記述します。

app/Services/TaskService.php

<?php
class TaskService
{
    /**
     * タスクリストの取得
     *
     * @return Collection タスクリスト
     */
    public function getTaskList()
    {
        return Task::all(); // 登録されている全タスクを取得
    }

    /**
     * タスク情報の取得
     *
     * @param int $taskId 取得するタスクID
     * @return Task タスク情報
     */
    public function getTask(int $taskId)
    {
        return Task::find($taskId); // 引数のタスクIDに対応するタスクを取得
    }

    /**
     * タスクの登録
     *
     * @param array $aryTask 登録するタスク情報
     */
    public function registTask(array $aryTask)
    {
        DB::beginTransaction();

        try
        {
            $task = new Task();
            $task->task_name  = $aryTask['task_name'];
            $task->task_contents  = $aryTask['task_contents'];
            $task->status_id  = $aryTask['status_id'];
            $task->due_date = $aryTask['due_date'];
            $task->save(); // タスクを新規に登録
            DB::commit();
        }
        catch (Exception $e)
        {
            DB::rollBack();
        }
    }

    /**
     * タスクの更新
     *
     * @param array $aryTask 更新するタスク情報
     */
    public function updateTask(array $aryTask)
    {
        DB::beginTransaction();

        try
        {
            $task = Task::find($aryTask['task_id']); // 更新するタスクを取得
            $task->task_name  = $aryTask['task_name'];
            $task->task_contents  = $aryTask['task_contents'];
            $task->status_id = $aryTask['status_id'];
            $task->due_date = $aryTask['due_date'];
            $task->save(); // タスクを更新
            DB::commit();
        }
        catch (Exception $e)
        {
            DB::rollBack();
        }
    }

    /**
     * タスクの削除
     *
     * @param int $userId 削除するタスクID
     */
    public function deleteTask(int $taskId)
    {
        Task::find($taskId)->delete(); // 引数のタスクIDに該当するタスクを削除
    }

    /**
     * ステータスリストの取得
     *
     * @return Collection ステータスリスト
     */
    public function getStatusList()
    {
        return Status::all(); // 全ステータスを取得
    }
}

Controller

  1. 下記コマンドを実行することで、コントローラークラスを作成します。
php artisan make:controller TaskController --resource

コントローラークラスは、app/Http/Controllersディレクトリに作成されます。

  1. コントローラークラスを以下のように変更します。
class TaskController extends Controller
{
    /**
     * タスクサービス
     */
    protected $taskService = null;

    /**
     * コンストラクタ
     *
     * @param TaskService $taskService タスクサービス
     */
    public function __construct(TaskService $taskService)
    {
        $this->taskService = $taskService;
    }

    /**
     * タスク一覧画面の初期表示
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $tasks = $this->taskService->getTaskList();
        return view('task.index')->with('tasks', $tasks);
    }

    /**
     * タスク登録画面の初期表示
     *
     * @return \Illuminate\Http\Response
     */
    public function create()
    {
        return view('task.create');
    }

    /**
     * タスク登録
     *
     * @param TaskFormRequest $request タスクフォームリクエスト
     * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
     */
    public function store(TaskFormRequest $request)
    {
        $aryTask = [];
        $aryTask['task_name'] = $request->taskName;
        $aryTask['task_contents'] = $request->taskContents;
        $aryTask['due_date'] = $request->dueDate;
        $aryTask['status_id'] = '01';
        $this->taskService->registTask($aryTask);
        return redirect('/tasks');
    }

    /**
     * タスク編集画面の初期表示
     *
     * @param  int  $taskId タスクID
     * @return \Illuminate\Http\Response
     */
    public function edit(int $taskId)
    {
        $task = $this->taskService->getTask($taskId);
        $statuses = $this->taskService->getStatusList();
        return view('task.edit')
                ->with('task', $task)
                ->with('statuses', $statuses);
    }

    /**
     * タスク更新
     *
     * @param TaskFormRequest $request タスクフォームリクエスト
     * @param  int  $taskId タスクID
     * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
     */
    public function update(TaskFormRequest $request, int $taskId)
    {
        $aryTask = [];
        $aryTask['task_id'] = $taskId;
        $aryTask['status_id'] = $request->statusId;
        $aryTask['task_name'] = $request->taskName;
        $aryTask['task_contents'] = $request->taskContents;
        $aryTask['due_date'] = $request->dueDate;

        $this->taskService->updateTask($aryTask);

        return redirect('/tasks');
    }

    /**
     * タスク削除
     *
     * @param  int  $taskId タスクID
     * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
     */
    public function destroy(int $taskId)
    {
        $this->taskService->deleteTask($taskId);
        return redirect('/tasks');
    }
}

FormRequest

  1. 下記コマンドを実行することで、フォームリクエストクラスを作成します。
php artisan make:request TaskFormRequest

フォームリクエストクラスは、app/Http/Requestsディレクトリに作成されます。

  1. フォームリクエストクラスを以下のように変更します。
class TaskFormRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'taskName' => 'required|max:50',
            'taskContents' => 'max:500',
        ];
    }

    public function messages()
    {
        return [
            'taskName.required' => 'タスク名を入力して下さい.',
            'taskName.max' => 'タスク名は最大50文字まで入力できます.',
            'taskContents.max' => 'タスク内容は最大500文字まで入力できます.',
        ];
    }
}

上記により、タスク名およびタスク内容の入力チェックを行います。

Router

  1. routes/web.phpファイルに下記を追加してルートを定義します。
Route::get      ('/tasks',                 'TaskController@index');
Route::get      ('/tasks/create',          'TaskController@create');
Route::post     ('/tasks/create',          'TaskController@store');
Route::get      ('/tasks/{userid}/edit',   'TaskController@edit');
Route::put      ('/tasks/{userid}/edit',   'TaskController@update');
Route::delete   ('/tasks/{userid}/delete', 'TaskController@destroy');

View

  1. 「resources/views」ディレクトリ直下に「task」ディレクトリを作成し、以下のファイルを作成します。
    今回は、bootstrapを使用して入力エラー時のメッセージを、各項目に表示するようにしています。

タスク登録画面

ファイル名:create.blade.php

<!doctype html>
<html lang="{{ app()->getLocale() }}">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <title>todo管理</title>
  </head>
  <body>
    <div class="container-fluid">
        <div class="card">
          <div class="card-header">タスク登録</div>
          <div class="card-body">
            {!! Form::open(['url' => '/task/create', 'method' => 'post']) !!}
              <div class="form-group">
                {!! Form::label('taskName', 'タスク名') !!}
                {!! Form::text('taskName', ($errors->has('taskName') ? old('taskName') : null), ['class' => 'form-control '. ($errors->has('taskName') ? 'is-invalid' : '')]) !!}
                <div class="invalid-feedback">{{$errors->first('taskName')}}</div>
              </div>
              <div class="form-group">
                {!! Form::label('taskContents', 'タスク内容') !!}
                {!! Form::textarea('taskContents', ($errors->has('taskContents') ? old('taskContents') : null), ['class' => 'form-control '. ($errors->has('taskContents') ? 'is-invalid' : ''), 'rows' => '3']) !!}
                <div class="invalid-feedback">{{$errors->first('taskContents')}}</div>
              </div>
              <div class="form-group">
                {!! Form::label('dueDate', '期限') !!}
                {!! Form::date('dueDate', null, ['class' => 'form-control']) !!}
              </div>
              {!! Form::submit('新規登録', ['class' => 'btn btn-primary']) !!}
              {!! link_to('/tasks', '戻る', ['class' => 'btn btn-primary']) !!}
            {!! Form::close() !!}
          </div>
        </div>
    </div>

    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  </body>
</html>

タスク一覧画面

ファイル名:index.blade.php

<!doctype html>
<html lang="{{ app()->getLocale() }}">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <title>todo管理</title>
  </head>
  <body>
    <div class="container-fluid">
        <div class="card">
          <div class="card-header">タスク一覧</div>
          <div class="card-body">
            <p>{!! link_to('/task/create', '新規登録', ['class' => 'btn btn-primary']) !!}</p>
            <table class="table table-hover table-sm">
              <thead class="thead-light">
              <tr>
                <th>ステータス</th>
                <th>タスク名</th>
                <th>タスク内容</th>
                <th>期限</th>
                <th>&nbsp;</th>
              </tr>
              </thead>
              <tbody>
              @foreach ($tasks as $task)
                <tr>
                  <td>{{$task->status->status_name}}</td>
                  <td>{{$task->task_name}}</td>
                  <td><pre>{{$task->task_contents}}</pre></td>
                  <td>{{$task->due_date}}</td>
                  <td>
                    <a href="{{ url('/task/'.$task->task_id.'/edit') }}" class="btn btn-link py-0 pr-0 border-0">編集</a>
                    {!! Form::open(['url' => '/task/'. $task->task_id. '/delete', 'method' => 'delete', 'class' => 'd-inline']) !!}
                      {!! Form::submit('削除', ['class' => 'btn btn-link py-0 border-0']) !!}
                    {!! Form::close() !!}
                  </td>
                </tr>
              @endforeach
              </tbody>
            </table>
          </div>
        </div>
      </div>

    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  </body>
</html>

タスク編集画面

ファイル名:edit.blade.php

<!doctype html>
<html lang="{{ app()->getLocale() }}">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <title>todo管理</title>
  </head>
  <body>
    <div class="container-fluid">
        <div class="card">
          <div class="card-header">タスク編集</div>
          <div class="card-body">
              {!! Form::open(['url' => '/task/'.$task->task_id .'/edit', 'method' => 'put']) !!}
                <div class="form-group">
                  {!! Form::label('statusId', 'ステータス') !!}
                  {!! Form::select('statusId', array_pluck($statuses, 'status_name', 'status_id'), $task->status_id, ['class' => 'custom-select']) !!}
                </div>
                <div class="form-group">
                  {!! Form::label('taskName', 'タスク名') !!}
                  {!! Form::text('taskName', ($errors->has('taskName') ? old('taskName') : $task->task_name), ['class' => 'form-control '. ($errors->has('taskName') ? 'is-invalid' : '')]) !!}
                  <div class="invalid-feedback">{{$errors->first('taskName')}}</div>
                </div>
                <div class="form-group">
                  {!! Form::label('taskContents', 'タスク内容') !!}
                  {!! Form::textarea('taskContents', ($errors->has('taskContents') ? old('taskContents') : $task->task_contents), ['class' => 'form-control '. ($errors->has('taskContents') ? 'is-invalid' : ''), 'rows' => '3']) !!}
                  <div class="invalid-feedback">{{$errors->first('taskContents')}}</div>
                </div>
                <div class="form-group">
                  {!! Form::label('dueDate', '期限') !!}
                  {!! Form::date('dueDate',$task->due_date, ['class' => 'form-control']) !!}
                </div>
                {!! Form::submit('編集', ['class' => 'btn btn-primary']) !!}
                {!! link_to('/tasks', '戻る', ['class' => 'btn btn-primary']) !!}
              {!! Form::close() !!}
          </div>
        </div>
    </div>

    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  </body>
</html>

最後に

laravelでの基本的な開発の流れを理解するために、簡単なtodo管理アプリを作成しました。 DIの機能(サービスコンテナ)により、責務を分離した実装がしやすいため、保守性が高いアプリを作りやすいと思います。

今回は、サービスクラスによってコントローラからビジネスロジックを分離しただけですが、 弊社ではビックデータを扱っているため、複数のデータストアを切り替えやすいリポジトリパターンも試してみるつもりです。