2018.12.10

入門Laravelチュートリアル (4) ToDoアプリのタスク一覧表示機能を作る


この連載記事では、Laravel を使用した Web アプリケーションの開発方法を紹介します。実際に(お決まりの?)ToDo アプリを開発する手順を通して Web 開発のエッセンスを学んでいただけるように書いていきます。取り扱う Laravel のバージョンは現時点で最新の 5.7 です。

👇 電子書籍版も公開しています。

第4章では、タスクの一覧表示機能を作っていきます。

前回の第3章でタスク一覧ページのルーティングは設定済みなので、今回はマイグレーションからスタートします。

マイグレーションとモデルクラス

マイグレーションファイルの作成と実行

まずはフォルダテーブルと同様にマイグレーションファイルを作成します。

$ php artisan make:migration create_tasks_table --create=tasks

タスクテーブルのテーブル定義をおさらいしましょう。

カラム論理名 カラム物理名 型の意味
ID id SERIAL 連番(自動採番)
フォルダID folder_id INTEGER 数値
タイトル title VARCHAR(100) 100文字までの文字列
状態 status INTEGER 数値
期限日 due_date DATE 日付
作成日 created_at TIMESTAMP 日付と時刻
更新日 updated_at TIMESTAMP 日付と時刻

こちらのテーブル定義をもとにマイグレーションファイルを記述します。

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateTasksTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('folder_id')->unsigned();
            $table->string('title', 100);
            $table->date('due_date');
            $table->integer('status')->default(1);
            $table->timestamps();

            // 外部キーを設定する
            $table->foreign('folder_id')->references('id')->on('folders');
        });
    }

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

カラムの型に合わせて定義を記述していく点はフォルダテーブルと一緒ですね。どの型にどのメソッドを使うかなどマイグレーションファイルの記述方法について詳しくはマニュアル(🇺🇸 公式 / 🇯🇵 日本語)を参照してください。

ここでのポイントは2点あります。まず状態カラムにデフォルト値を設定している点です。

$table->integer('status')->default(1);

タスクを作成した最初の状態は必ず「未着手」状態でしょうから、何も指定しないときは「1」が入るようにしておきます。

次に外部キー制約を設定している点に注目してください。

// 外部キー制約を設定する
$table->foreign('folder_id')->references('id')->on('folders');

外部キー制約は他のテーブルとの結びつきを表現するためのカラムに設定します。外部キー制約が設定されたカラムには、好き勝手な値は入れられなくなります。今回の例で言うと、タスクテーブルのフォルダID列には実際に存在するフォルダIDの値しか入れることができなくなります。これによりデータの不整合を防ぐわけです。

さてマイグレーションファイルが書けたらコマンドラインからマイグレーションを実行しましょう。

$ php artisan migrate

データベースクライアントでテーブルが作成できているか確認しておいてください。

モデルクラスの作成

続いてタスクテーブルに対応するモデルクラスを作成します。

$ php artisan make:model Task

タスククラスはとりあえずできたままの状態で OK です。

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    //
}

テストデータの作成と確認

シーダーを作成する

テストデータを挿入するためにシーダーを作成します。

$ php artisan make:seeder TasksTableSeeder

database/seeds/TasksTableSeeder.php を以下の通り記述してください。

TasksTableSeeder.php
<?php

use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class TasksTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        foreach (range(1, 3) as $num) {
            DB::table('tasks')->insert([
                'folder_id' => 1,
                'title' => "サンプルタスク {$num}",
                'status' => $num,
                'due_date' => Carbon::now()->addDay($num),
                'created_at' => Carbon::now(),
                'updated_at' => Carbon::now(),
            ]);
        }
    }
}

ID = 1 のフォルダに対して3つのタスクを登録します。ちなみに due_date には Carbon ライブラリの addDay メソッドを利用してそれぞれ現在時間から1〜3日加算した日付を指定しています。Carbon は非常に便利な日付操作ライブラリなので覚えておくとよいですね。

さて、書けたら実行してください。

$ php artisan db:seed --class=TasksTableSeeder

上記のコマンドがうまくいかない場合は composer dump-autoload コマンドを打ってから実行し直してください。

tinker を使ってみよう

ここでテストデータの確認のために、Laravel の便利なコマンド tinker を使います。まずは以下のコマンドを実行してください。

$ php artisan tinker

tinker を使うと、コマンドラインからアプリケーションの機能を確かめることができます。Web アプリなので本来であれば画面を通してしか確認できない機能をコマンドラインで確かめられるのが便利なポイントです。

さて上記のコマンドを実行すると、以下の出力が返ってきます。>>> はユーザーからの入力を待ち受けているという意味です。

Psy Shell v0.9.9 (PHP 7.2.10 — cli) by Justin Hileman
>>>

フォルダクラスで、ID に合致するデータを取得する find メソッドを実行してみましょう。

>>> $folder = \App\Folder::find(1);

このように、find の結果が出力されます。

=> App\Folder {#2899
     id: 1,
     title: "プライベート",
     created_at: "2018-11-25 13:35:48",
     updated_at: "2018-11-25 13:35:48",
   }

ではタスクのデータを確認しましょう。

>>> \App\Task::where('folder_id', $folder->id)->get();

このあとのコントローラーの項でも説明しますが、取得条件を指定するメソッド where を使って、フォルダIDカラムの値が先ほど取得したフォルダのID(=1)に合致するタスクを取得しています。

先ほどシーダーで挿入したデータが取得できていますね!

=> Illuminate\Database\Eloquent\Collection {#2900
     all: [
       App\Task {#2901
         id: 1,
         folder_id: 1,
         title: "サンプルタスク 1",
         due_date: "2018-11-26",
         status: 1,
         created_at: "2018-11-25 16:04:59",
         updated_at: "2018-11-25 16:04:59",
       },
       App\Task {#2905
         id: 2,
         folder_id: 1,
         title: "サンプルタスク 2",
         due_date: "2018-11-27",
         status: 2,
         created_at: "2018-11-25 16:05:00",
         updated_at: "2018-11-25 16:05:00",
       },
       App\Task {#2906
         id: 3,
         folder_id: 1,
         title: "サンプルタスク 3",
         due_date: "2018-11-28",
         status: 3,
         created_at: "2018-11-25 16:05:00",
         updated_at: "2018-11-25 16:05:00",
       },
     ],
   }

コントローラー

ではコントローラーを書いていきましょう。

まず TaskController.php の冒頭で Task モデルを読み込みます。

TaskController.php
use App\Folder;
use App\Task; // ★ 追加
use Illuminate\Http\Request;

次に index メソッドを以下のように編集します。

TaskController.php
public function index(int $id)
{
    // すべてのフォルダを取得する
    $folders = Folder::all();

    // 選ばれたフォルダを取得する
    $current_folder = Folder::find($id);

    // 選ばれたフォルダに紐づくタスクを取得する
    $tasks = Task::where('folder_id', $current_folder->id)->get();

    return view('tasks/index', [
        'folders' => $folders,
        'current_folder_id' => $current_folder->id,
        'tasks' => $tasks,
    ]);
}

find メソッド

まず find メソッドは、プライマリキーのカラムを条件として一行分のデータを取得してきます。

Folder::find(1);

上の例でいうと、フォルダテーブルから ID カラム(プライマリキー)が 1 である行のデータを検索して返します。

クエリビルダ

where メソッド

次に where メソッドに注目してください。先ほど少し触れましたが、Laravel が提供するクエリビルダの機能を使っています。クエリとは、SQL クエリのことです。ビルダとは構築者という意味ですね。クエリビルダの機能によって SQL を書かなくても PHP 風な記述でデータ操作を表現できます。SQL は裏側で生成されデータベースに発行されます。

Tasks::where('folder_id', $current_folder->id)->get();

where メソッドはデータの取得条件を表し、SQL の WHERE 句にあたります。第一引数がカラム名で第二引数が比較する値です。ただ厳密にいうと、上記の記述は以下の記述の省略形です。

Tasks::where('folder_id', '=', $current_folder->id)->get();

引数を三つ与えると、イコール以外の比較も可能だということです。

注意していただきたいのは最後の get メソッドです。この get メソッドで構築された SQL をデータベースに発行して結果を取得しています。ですので、

Tasks::where('folder_id', $current_folder->id);

ここまでではまだ SQL 作っている段階なので値は取れません。get メソッドを忘れて「値が取れないぞ??」となりがちなので気をつけましょう。
(少し突っ込んでいうと where からは QueryBuilder クラスのインスタンスが返ってきます。QueryBuilder の get メソッドを呼ぶことで結果の詰まった Collection クラスのインスタンスが返ってきます。)

クエリビルダには他にも SQL を表現するたくさんの機能があります。詳しくはマニュアル(🇺🇸 公式 / 🇯🇵 日本語)を参照してください。

クエリビルダで SQL を書かなくてよくなると言ってもこの機能は SQL の代替ではありません。あくまで SQL を組み立てる機能ですので、やはり SQL の知識は必要になります。

またクエリビルダの利点は SQL を書かないことではなく(SQL を書かないのが良いとか悪いという話ではない)、SQL を PHP コードによって表現することによって条件の部品化や使い回しが効いてプログラムの生産性や可読性が向上することにあります。

tinker で SQL を確認する

またまた tinker を使って、今度はクエリビルダでどんな SQL が作られているのか覗いてみましょう。get メソッドの代わりに toSql メソッドを呼び出すと SQL を発行するのではなく、作った SQL を文字列で出力します。

$ php artisan tinker

Psy Shell v0.9.9 (PHP 7.2.10 — cli) by Justin Hileman

>>> \App\Task::where('folder_id', 1)->toSql();
=> "select * from "tasks" where "folder_id" = ?"

クエリビルダによって SQL が作成されていることが確認できますね。

テンプレート

さて今度はテンプレートを書きましょう。前回のタスク一覧ページのテンプレートファイル resources/views/tasks/index.blade.php<!-- ここにタスクが表示される --> とコメントが書かれていた箇所に以下のコードを記述してください。

index.blade.php
<div class="panel panel-default">
  <div class="panel-heading">タスク</div>
  <div class="panel-body">
    <div class="text-right">
      <a href="#" class="btn btn-default btn-block">
        タスクを追加する
      </a>
    </div>
  </div>
  <table class="table">
    <thead>
    <tr>
      <th>タイトル</th>
      <th>状態</th>
      <th>期限</th>
      <th></th>
    </tr>
    </thead>
    <tbody>
      @foreach($tasks as $task)
        <tr>
          <td>{{ $task->title }}</td>
          <td>
            <span class="label">{{ $task->status }}</span>
          </td>
          <td>{{ $task->due_date }}</td>
          <td><a href="#">編集</a></td>
        </tr>
      @endforeach
    </tbody>
  </table>
</div>

コントローラーから渡された tasks をループして一つ一つのタスクデータを出力しています。

ここまでできたら一度ブラウザで確認しましょう。/folders/1/tasks にアクセスしてみてください。

...いかがでしょうか?タスクは3つ表示されているはずです。ただいくつか課題があります。

  • 状態が数値で表示されている。
    1 →「未着手」、2 →「着手中」、3 →「完了」と表示したい。
  • 状態ラベルを色分けできていない。
    「未着手」は赤、「着手中」は緑、「完了」は灰色にしたい。
  • 日付の形式はハイフン区切りではなくスラッシュ区切りにしたい。

次の項ではモデルクラスのアクセサという機能を使ってこれらの課題を改善します。

Task モデルにアクセサを追加する

アクセサとは

アクセサとは、モデルクラスが本来持つデータを加工した値を、さもモデルクラスのプロパティであるかのように参照できる Laravel の機能です。

まずモデルクラスに get○○○Attribute という決まったフォーマットのメソッドを用意します。以下の例では性別の文字列表現を取得するための getGenderTextAttribute メソッドを記述しました。persons テーブルに gender(性別)カラムがあるイメージです。

class Person extends Model
{
    public function getGenderTextAttribute()
    {
        switch($this->attributes['gender']) {
            case 1:
                return 'male';
            case 2;
                return 'female';
            default:
                return '??';
        }
    }
}

まず $this->attributes['gender'] に注目してください。これで gender カラムの値を取得しています。実は Laravel ではモデルクラスの属性データ(テーブルで言うところの各カラムの値)はそれぞれがクラスのプロパティで管理されているのではなく、$attributes という一つのプロパティで配列として管理されています。

前回の章でも $folder->title とクラスのプロパティのようにタイトルを取得していましたが、これは PHP のマジックメソッド __get() を利用しています。本当は Folder クラスには title というプロパティは存在しません(定義しなかったですよね)。モデルクラスは、存在しないプロパティを参照されると $attributes からプロパティ名と一致するキーの値が返すように実装されているのです。

さて getGenderTextAttribute メソッドでは gender カラムの値に応じて文字列を返していますね。そしてこれをどう使うかというと...

// コントローラーやテンプレートなどで...
$person->gender_text; //=> 'male'

上記のように、ここでもクラスのプロパティかのように getGenderTextAttribute メソッドの出力値を参照できています。get○○○Attribute○○○ の部分が(擬似的な)プロパティになっていますね。ただしアクセサメソッドはキャメルケース(文字の区切りが大文字)ですがプロパティとして参照するときはスネークケース(文字の区切りがアンダースコア)になります。

ルールが分かってしまえば難しくはないですね。「アクセサを使わずに普通にモデルにメソッドを用意して普通にそれを呼び出しても同じでは?」と考える方もいらっしゃるかもしれません。まぁその通りです。ただアクセサを利用したほうがちょっとコードがキレイになります。公式サイトのトップページに "Love beautiful code? We do too." とあるように、コードをキレイに見せることは Laravel のコンセプトの一つであるようです。

アクセサの使いどころは、モデルが持つ属性データ(テーブルで言うところの各カラムの値)を加工した値を取得したいときです。実際に今回の ToDo アプリでも使ってみましょう。

状態の名前を表示する

まずは状態を数値ではなく文字列で表示する機能です。

モデル

タスクモデルに定数 STATUSgetStatusLabelAttribute メソッドを追加してください。

Task.php
class Task extends Model
{
    /**
     * 状態定義
     */
    const STATUS = [
        1 => [ 'label' => '未着手' ],
        2 => [ 'label' => '着手中' ],
        3 => [ 'label' => '完了' ],
    ];

    /**
     * 状態のラベル
     * @return string
     */
    public function getStatusLabelAttribute()
    {
        // 状態値
        $status = $this->attributes['status'];

        // 定義されていなければ空文字を返す
        if (!isset(self::STATUS[$status])) {
            return '';
        }

        return self::STATUS[$status]['label'];
    }
}

$this->attributes['status'] で状態カラムの値を取得している以外は、getStatusLabelAttribute メソッドの中身は普通の PHP です。STATUS 配列から状態値をキーに文字列表現を探して返しています。

テンプレート

テンプレートは、{{ $task->status }} と記述していた箇所を書き換えてください。

<span class="label">{{ $task->status_label }}</span>

これでもう一度ブラウザで確認してみましょう。状態が文字列で表示されたのではないでしょうか。

状態を色分けする

次に状態のラベルを色分けする機能を追加します。
状態の文字列表現を表示したのとほとんど同じ要領です。

モデル

STATUS 配列に class を追加して、getStatusClassAttribute メソッドを追記します。

Task.php
/**
 * 状態定義
 */
const STATUS = [
    1 => [ 'label' => '未着手', 'class' => 'label-danger' ],
    2 => [ 'label' => '着手中', 'class' => 'label-info' ],
    3 => [ 'label' => '完了', 'class' => '' ],
];

/**
 * 状態を表すHTMLクラス
 * @return string
 */
public function getStatusClassAttribute()
{
    // 状態値
    $status = $this->attributes['status'];

    // 定義されていなければ空文字を返す
    if (!isset(self::STATUS[$status])) {
        return '';
    }

    return self::STATUS[$status]['class'];
}

中身は getStatusLabelAttribute メソッドとほとんど一緒ですね。label の代わりに class を返しています。

テンプレート

テンプレートでは、ラベルの部分のクラス属性に $task->status_class を追加します。

<span class="label {{ $task->status_class }}">{{ $task->status_label }}</span>

これで状態に応じた HTML クラスが出力され、結果的に CSS でそれぞれの色が描画されます。

日付の表示形式を変更する

最後に日付の表示形式を変更します。データベースに入ったままだと 2019-01-01 というハイフン区切りの形式なので、より一般的な 2019/01/01 というスラッシュ区切りの形式で表示しましょう。

モデル

モデルクラスでは、getFormattedDueDateAttribute メソッドを追加します。

Task.php
// この行を追加
use Carbon\Carbon;

class Task extends Model
{
    /* 中略 */

    /**
     * 整形した期限日
     * @return string
     */
    public function getFormattedDueDateAttribute()
    {
        return Carbon::createFromFormat('Y-m-d', $this->attributes['due_date'])
            ->format('Y/m/d');
    }
}

Carbon ライブラリを使って期限日の値の形式を変更して返却しています。

テンプレート

もうなんとなく分かってきたかもしれませんが、テンプレートでは $task->due_date だった箇所を書き換えます。

<td>{{ $task->formatted_due_date }}</td>

ブラウザで表示形式が変わっているか確認しましょう。


タスク一覧まできちんと表示できましたので、ここまでで見た目の上ではこの章で作りたい段階は達成しています。ただ、内部のコードはもう少しキレイにすることができます。そこでもう一つだけ Laravel の強力な機能、「テーブル間の関連性をモデルクラスで表現する方法」を紹介してこの章を終わりたいと思います。

モデルクラスにおけるリレーション

タスクコントローラーではタスクの一覧を以下のコードで取得しました。

$tasks = Tasks::where('folder_id', $current_folder->id)->get();

ここで紹介する機能を使うとこのように書き直すことができます。

$tasks = $current_folder->tasks()->get();

特定のフォルダに紐づくタスクの一覧を取得する処理が、ほとんど左から英語で読んだ通りの直感的な表現で記述されています。では実装方法を説明していきます。

hasMany

フォルダクラスを編集します。以下の通り tasks メソッドを追加してください。

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Folder extends Model
{
    public function tasks()
    {
        return $this->hasMany('App\Task');
    }
}

tasks メソッドの中で hasMany メソッドを呼び出していますが、これにより Laravel はフォルダテーブルとタスクテーブルの関連性(リレーション)を「たどって」、フォルダクラスのインスタンスから紐づくタスククラスのリストを取得します。

hasMany('App\Task') は「タスクをたくさん持っている」と読めますね。文字通りフォルダテーブルとタスクテーブルの一対多の関連性を利用するように Laravel に指示しているわけです。

ちなみに上記のhasMany メソッドは引数を省略しています。省略せずに書くと以下の通りです。

$this->hasMany('App\Task', 'folder_id', 'id');

第一引数が関連するモデル名(名前空間も含む)、第二引数が関連するテーブルが持つ外部キーカラム名、第三引数はモデルに hasMany が定義されている側のテーブルが持つ、外部キーに紐づけられたカラムの名前です。ただ第二引数と第三引数は今回のようにある決まりに沿っている場合は省略できます。その決まりとは、第二引数が テーブル名単数形_id で第三引数が id であることです。

つまり、Laravel はフォルダテーブルとタスクテーブルを結びつけるためのカラムまで知っているのでフォルダさえわかればタスクも分かってしまうのです。

モデルで定義できるリレーションの機能について詳しくはマニュアル(🇺🇸 公式 / 🇯🇵 日本語)も参照してください。

ちなみに tinker で SQL を確認すると以下のような出力が得られます。

>>> $folder = \App\Folder::find(1);
>>> $folder->tasks()->toSql();
=> "select * from "tasks" where "tasks"."folder_id" = ? and "tasks"."folder_id" is not null"

folder_id カラムを条件にタスクデータを取得する SQL が作られていますね。

index メソッドを編集する

ではリレーションを使うようにタスクコントローラーの index メソッドを編集しましょう。

TaskController.php
public function index(int $id)
{
    // すべてのフォルダを取得する
    $folders = Folder::all();

    // 選ばれたフォルダを取得する
    $current_folder = Folder::find($id);

    // 選ばれたフォルダに紐づくタスクを取得する
    $tasks = $current_folder->tasks()->get(); // ★

    return view('tasks/index', [
        'folders' => $folders,
        'current_folder_id' => $current_folder->id,
        'tasks' => $tasks,
    ]);
}

★印をつけた行を書き換えました。
できたら見た目が変わっていないことをブラウザで確認しましょう。

🍇 🍇 🍇

第4章はこれでおしまいです。
ここまでのソースコードはリポジトリ(chapter04 ブランチ)を参照してください。

次の章ではフォルダを作成する機能を実装します。

連載記事