1. Home
  2. PHP
  3. Laravel
  4. LaravelのEloquentORMでモデルベースのDBリレーション~基本からEagerロードまで~

LaravelのEloquentORMでモデルベースのDBリレーション~基本からEagerロードまで~

  • 公開日
  • 更新日
  • カテゴリ:Laravel
  • タグ:PHP,Laravel,Eloquent
LaravelのEloquentORMでモデルベースのDBリレーション~基本からEagerロードまで~

Laravel で提供されている Eloquent は、モデルを定義しデータベースの操作を行う O/R マッパーです。

今回は、Eloquent でのリレーションの定義や考え方を見ていきます。

Contents

  1. 開発環境
  2. リレーション
  3. hasOne - 1対1
  4. belongsTo - 1対1
  5. hasMany - 1対多
  6. belongsTo - 1対多
  7. belongsToMany - 多対多
    1. 中間テーブル情報を除外してデータ取得
    2. 中間テーブルのフィールド取得
  8. hasManyThrough
    1. 別方向からのリレーションアプローチ
  9. morphMany - ポリモーフィック
    1. 格納するモデルタイプの値
  10. morphToMany - 多対多ポリモーフィック
  11. morphedByMany
  12. リレーションのクエリ
    1. has()
    2. whereHas() / orWhereHas()
    3. doesntHave() / orDoesntHave()
    4. whereDoesntHave() / orWhereDoesntHave()
    5. withCount()
  13. Eager ロード

開発環境

今回の開発環境は以下の通りです。

  • MySQL 8.0
  • PHP 7.3
  • Laravel 5.8

モデルクラスは app/Models 配下へ設置しています。

EloquentORM の基本に関しては以下を参考にしてください。
Laravel の EloquentORM の基本から具体的な使い方

リレーション

データベースには1つのスキーマに複数のテーブルがあり、その中にレコードが格納されていきます。

基本的にテーブルを分けるのは格納するデータの性質(ここでいう性質とは、例えばこっちのテーブルは野球チームのデータの集合、あっちのテーブルはサッカーチームのデータの集合。の様な事)が違うからですが、同じ性質を持ったテーブルでも敢えて分ける事で、情報を整理整頓し、効率的にデータベースを使用する事が出来ます。

その時にはテーブル同士を繋げてさせて1つの情報(データ)を形成するのですが、それを「リレーション(結合)」と言います。

Eloquent では、その結合に関するパターン(テーブル同士の関係)をモデルクラスへ定義する事でリレーションを行う事が出来ます。

hasOne - 1対1

1 対 1 は、結合元テーブルのリレーションさせたい値に対して、結合先テーブルの対応する値が必ず1つのみである関係を言います。

例えば何らかの組織のメンバーを格納しているテーブルと、そのメンバーに貸与される電話番号テーブルがあるとします。

各メンバーが持つ電話番号は1つであり、複数貸与される事はありません。

つまり、メンバーテーブルのレコード1つが持つ電話番号情報は、電話番号テーブルに格納されているレコードのどれか1つだけに必ず合致する。

これが「1 対 1」の関係です。

これを Eloquent のモデルで設定すると以下のようになります。

laravel/app/Models/Member.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Member extends Model
{
    public function phone()
    {
        return $this->hasOne('App\Models\Phone');
    }
}

1 対 1 を定義する場合は hasOne() メソッドを使い、引数に結合対象のモデルを指定します。

また、上記の場合は暗黙的に id を外部キー member_id とリレーションさせる想定で動作します。つまり、members テーブルの id と phones テーブルの member_id をリレーションさせるという事です。

これを任意のカラムに変更するには、以下のように記述します。

return $this->hasOne('App\Models\Phone', 'foreign_key', 'local_key');

第二引数に外部キー(foreignKey)を指定し、第三引数にローカルキー(localKey)を指定します。これで members テーブルの local_key と phones テーブルの foreign_key をリレーションします。

モデルの設定を行うとデータ取得の際にリレーションが行われ結合先のデータも取得できます。

laravel/app/Http/Controllers/SampleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Member;

class SampleController extends Controller
{
    public function index()
    {
        $data = [];
        $members = Member::all();

        foreach ($members as $member) {
            $data[] = [
                // membersテーブル - nameカラム
                'name'  => $member->name,
                // phonesテーブル - numberカラム
                'phone' => $member->phone->number
            ];
        }

        // print_r($data);
        // => Array
        //(
        //    [0] => Array
        //        (
        //            [name] => Michael Ritchie
        //            [phone] => 09000000001
        //        )
        //
        //    [1] => Array
        //        (
        //            [name] => Miss Caroline Howell
        //            [phone] => 09000000002
        //        )
        //
        //    [2] => Array
        //        (
        //            [name] => Verna Jacobi
        //            [phone] => 09000000003
        //        )
        //     .
        //     .
        //     .
        //     .
    }
}

メンバーのレコード1件につき、電話番号情報を1件取得できている事が確認できます。

ポイントとしては、結合先テーブルにアクセスする際はテーブル名ではなくモデルで定義したメソッド名で行う点です。

$member->phone->number // 「phone 」はメソッド名でアクセス

public function phone() // モデルでリレーション定義したメソッド

リレーションしたのは phones テーブルですが、アクセスする際はモデルで定義したメソッド名の phone です。

belongsTo - 1対1

1 対 1, つまり hasOne() でリレーションした両テーブルの関係は、「主たるテーブルと、それを補うテーブル」という事になりますが、この関係で逆からの取得を行いたい場合もあります。

今回の例で言えば、phones テーブルから members テーブルの情報を取得する場合です。この場合には、Phone 側のモデルを belongsTo() で定義します。

laravel/app/Models/Phone.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Phone extends Model
{
    public function member()
    {
        return $this->belongsTo('App\Models\Member');
    }
}

書式は同一で、belongsTo() メソッドにモデルの名前空間を引数に渡します。そしてこの場合は、暗黙的に phones テーブルの member_id と members テーブルの id を結合させます。

外部キーやオーナーキーを任意のカラムに変更したい場合は以下のように記述します。

return $this->belongsTo('App\Models\Member', 'foreign_key_name', 'own_key_name');

データ取得を行います。

laravel/app/Http/Controllers/SampleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Phone;

class SampleController extends Controller
{
    public function index()
    {
        $data = [];
        $phones = Phone::all();
        foreach ($phones as $phone) {
            $data[] = [
                // phonesテーブル - numberカラム
                'phone'  => $phone->number,
                // membersテーブル - nameカラム
                'name' => $phone->member->name
            ];
        }

        // print_r($data);
        // => Array
        //(
        //    [0] => Array
        //        (
        //            [phone] => 09000000001
        //            [name] => Michael Ritchie
        //        )
        //
        //    [1] => Array
        //        (
        //            [phone] => 09000000002
        //            [name] => Miss Caroline Howell
        //        )
        //
        //    [2] => Array
        //        (
        //            [phone] => 09000000003
        //            [name] => Verna Jacobi
        //        )
        //     .
        //     .
        //     .
        //     .
    }
}

補完側である phones テーブルから主体である members テーブルの値を取得出来ている事が確認できました。

hasMany - 1対多

members テーブルと comments テーブルがあるとします。コメントテーブルには、メンバーのコメントが時系列に格納されています。

これを結合関係にした場合、メンバーは複数のコメントを持っている事になります。つまり、members テーブルのレコードに紐づく comments テーブルのレコードが複数ある。という事になります。

この関係を「1 対多」であるといいます。

この関係性をもったテーブルをリレーションするには hasMany() としてモデルに定義します。

laravel/app/Models/Member.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Member extends Model
{
    public function comments()
    {
        return $this->hasMany('App\Models\Comment');
    }
}

この場合も、暗黙的に members テーブルの id と comments テーブルの member_id を結合します。

外部キーとローカルキーを変更する場合は以下のように記述します。

return $this->hasMany('App\Models\Comment', 'foreign_key', 'local_key');

第二引数に外部キーを、第三引数にローカルキーを渡します。これで、members テーブルの local_key カラムと、comments テーブルの foreign_key カラムによってリレーションが行われるようになります。

データを取得します。

laravel/app/Http/Controllers/SampleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Member;

class SampleController extends Controller
{
    public function index()
    {
        $members = Member::all();
        $data = [];
        foreach ($members as $member) {
            $data[] = [
                // membersテーブル - nameカラム
                'name'     => $member->name,
                // commentsテーブル - id/comment/created_atカラムの配列
                'comments' => $member->comments()->select('id', 'comment', 'created_at')->get()->toArray()
            ];

            print_r($data);
            // => Array
            //(
            //    [0] => Array
            //        (
            //            [name] => Michael Ritchie
            //            [comments] => Array
            //                (
            //                    [0] => Array
            //                        (
            //                            [id] => 4
            //                            [comment] => Rerum distinctio amet in et.
            //                            [created_at] => 2019-08-15 12:47:58
            //                        )
            //
            //                    [1] => Array
            //                        (
            //                            [id] => 12
            //                            [comment] => Molestias aut ipsum est recusandae.
            //                            [created_at] => 2019-08-15 12:47:58
            //                        )
            //
            //                    [2] => Array
            //                        (
            //                            [id] => 37
            //                            [comment] => Voluptatem consectetur id quisquam maiores.
            //                            [created_at] => 2019-08-15 12:47:58
            //                        )
            //                    .
            //                    .
            //                    .
            //                    .
        }

    }
}

1件のユーザレコードに対して複数件のコメントレコードを持つデータを取得できている事が確認できます。

belongsTo - 1対多

逆の、comments テーブルから members テーブルの情報を取得したい場合は、モデルを belongsTo() メソッドで定義します。

laravel/app/Models/Comment.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
    public function member()
    {
        return $this->belongsTo('App\Models\Member');
    }
}

データを取得します。

laravel/app/Http/Controllers/SampleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Comment;

class SampleController extends Controller
{
    public function index()
    {
        $comments = Comment::all();
        $data = [];
        foreach ($comments as $comment) {
            $data[] = [
                // commentsテーブル - comment
                'comment' => $comment->comment,
                // membersテーブル - name
                'name'    => $comment->member->name
            ];
        }

        // print_r($data);
        // => Array
        //(
        //    [0] => Array
        //        (
        //            [comment] => Itaque perferendis quia qui corrupti voluptatem facilis repudiandae quas.
        //            [name] => Verna Jacobi
        //        )
        //
        //    [1] => Array
        //        (
        //            [comment] => Deleniti qui sequi illum maxime sunt et.
        //            [name] => Bianka Ferry
        //        )
        //
        //    [2] => Array
        //        (
        //            [comment] => Sint alias mollitia dolores porro id nihil distinctio.
        //            [name] => Bianka Ferry
        //        )
        //     .
        //     .
        //     .
        //     .

    }
}

コメントに紐づくユーザー名が取得できている事が確認できました。

belongsToMany - 多対多

リレーションとは基本的に複数のテーブルの結合関係を表しますが、その互いのテーブルにおいて、それぞれが、それぞれ複数の対象を持つ場合の結合関係を「多対多」といいます。

例えば、ブログ記事とそれに設定するタグで考えてみます。

記事はそれぞれいくつでも好きなだけ関連するタグを設定でき、タグ自身も、複数の記事に設定されます。

この場合、記事テーブルのタグ ID カラムには複数のタグ ID を入れる必要があり、同じくタグテーブルにも、1つのタグレコードの記事 ID カラムには複数の記事 ID を入れなければなりません。が、、データベースを簡潔に使おうと思ったらそれは難しい要件となります。

そこで、「中間テーブル」を用意する事で、この多対多の関係を構築する事が出来ます。

中間テーブルはその名の通り、対象のテーブルとテーブルの中間を保持するテーブルの事で、この例の場合ではブログ記事 ID とタグ ID を保持するレコードを持つ事で、両者をリレーションする。という仕組みになります。

各テーブルのカラム構成を見ると理解しやすいです。

// 記事テーブル
mysql> SHOW COLUMNS FROM `posts`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| member_id  | int(10) unsigned | NO   | MUL | NULL    |                |
| post       | text             | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

// タグテーブル
mysql> SHOW COLUMNS FROM `tags`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| tag        | varchar(255)     | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

// 中間テーブル
mysql> SHOW COLUMNS FROM `post_tag`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| post_id    | int(10) unsigned | NO   |     | NULL    |                |
| tag_id     | int(10) unsigned | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

中間テーブルでは記事 ID とタグ ID を持ち、記事テーブルとタグテーブルを結びつける役割を担っている事がわかります。

中間テーブルを持つ事で「多対多」を表現でき、そしてこの「多対多」の関係性を築く事で、双方のレコードがそれぞれ複数の対象を持っていてもリレーションが可能になります。

この「多対多」のリレーションを行うには、モデルで belongsToMany() メソッドを定義します。

laravel/app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    public function tags()
    {
        return $this->belongsToMany('App\Models\Tag');
    }
}

上記は最も最小の記述になりますが、この場合は以下が暗黙のルールとして処理が行われます。

  1. posts テーブルと tags テーブルのリレーションである
  2. 中間テーブルに post_tag テーブルを使う
  3. posts テーブルの idpost_tag テーブルの post_id が結合する
  4. tags テーブルの idpost_tag テーブルの tag_id が結合する

よって、テーブル名や外部キーなどがこの暗黙ルールに沿っていない場合は、以下のように記述することで任意のカラム名などに変更できます。

return $this->belongsToMany(
    'App\Models\Tag',
    'pivotTable(=post_tag)',     // 中間テーブル名
    'foreignPivotKey(=post_id)', // 中間テーブルにあるFK
    'relatedPivotKey(=tag_id)'   // リレーション先モデルのFK
);

データを取得します。

laravel/app/Http/Controllers/SampleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Support\Arr;

class SampleController extends Controller
{
    public function index()
    {
        $posts = Post::all();
        $data = [];
        foreach ($posts as $post) {
            $data[] = [
                // postsテーブル
                'id' => $post->id,
                'post' => $post->post,
                // tagsテーブル(タグ名のみを抽出)
                'tags' => Arr::pluck($post->tags()->select('tag')->get()->toArray(), 'tag')
            ];
        }

        // print_r($data);
        // => Array
        //(
        //    [0] => Array
        //        (
        //            [id] => 1
        //            [post] => post 01
        //            [tags] => Array
        //                (
        //                    [0] => dolores
        //                    [1] => incidunt
        //                )
        //        )
        //
        //    [1] => Array
        //        (
        //            [id] => 2
        //            [post] => post 02
        //            [tags] => Array
        //                (
        //                    [0] => in
        //                    [1] => ullam
        //                    [2] => ut
        //                    [3] => consequatur
        //                )
        //        )
        //     .
        //     .
        //     .
        //     .

    }
}

記事ベースで関連する複数のタグを取得できている事が確認できました。

ちなみにこの逆、タグテーブルから記事を取得する場合も、同じく belongsToMany() メソッドでモデルを定義してやれば取得が可能です。

laravel/app/Models/Tag.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    public function post()
    {
        return $this->belongsToMany('App\Models\Post');
    }
}
laravel/app/Http/Controllers/SampleController.php
<?php

namespace App\Http\Controllers;

use App\Models\Tag;

class SampleController extends Controller
{
    public function index()
    {
        $tags = Tag::all();
        $data = [];
        foreach ($tags as $tag) {
            $data[] = [
                // tagsテーブル
                'id' => $tag->id,
                'tag' => $tag->tag,
                // postsテーブル(postのみ抽出)
                'post' => $tag->post()->select('post')->first()->post
            ];
        }

        // print_r($data);
        // => Array
        //(
        //    [0] => Array
        //        (
        //            [id] => 1
        //            [tag] => dolores
        //            [post] => post 01
        //        )
        //
        //    [1] => Array
        //        (
        //            [id] => 2
        //            [tag] => ut
        //            [post] => post 02
        //        )
        //
        //    [2] => Array
        //        (
        //            [id] => 3
        //            [tag] => voluptatem
        //            [post] => post 03
        //        )
        //     .
        //     .
        //     .
        //     .
    }
}

タグベースで、紐づく記事を取得出来ている事が確認できました。

中間テーブル情報を除外してデータ取得

多対多では、結果コレクションに中間テーブル情報が付与されてきます。

//  => Array
//        (
//            [id] => 2
//            [member_id] => 2
//            [post] => post 02
//            [created_at] => 
//            [updated_at] => 
//            [pivot] => Array
//                (
//                    [tag_id] => 1
//                    [post_id] => 2
//                )
//
//        )

リレーションした結合元と結合先の id が付与されますが、これを取得したく無い場合は、モデルにその旨を定義する事で、除外する事が出来ます。

laravel/app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $hidden = ['pivot']; // 中間テーブル情報を取得しない

    public function tags()
    {
        return $this->belongsToMany('App\Models\Tag');
    }
}
laravel/app/Models/Tag.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    protected $hidden = ['pivot']; // 中間テーブル情報を取得しない

    public function post()
    {
        return $this->belongsToMany('App\Models\Post');
    }
}

中間テーブルのフィールド取得

前途の通り、基本的に結果コレクションには中間テーブル情報が付与されてきますが、他の中間テーブルのカラムを取得する場合はモデルクラスにアクセスしたいカラムを指定しておき、取得時に pivot プロパティにアクセスします。

laravel/app/Models/Posts.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    public function tags()
    {
        return $this->belongsToMany('App\Models\Tag')->withPivot('created_at', 'updated_at');
    }
}
laravel/app/Http/Controllers/SampleController.php
public function index()
{
    $post = Post::find(1);
    $data['post'] = $post->post;
    foreach ($post->tags as $t) {
        $data['tag'] = $t->tag;
        $data['pivot'] = [
            'created_at' => $t->pivot->created_at->format('Y-m-d H:i:s'),
            'updated_at' => $t->pivot->updated_at->format('Y-m-d H:i:s')
        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [post] => post 01
    //    [tag] => fuga
    //    [pivot] => Array
    //        (
    //            [created_at] => 2019-08-16 07:16:18
    //            [updated_at] => 2019-08-16 07:16:18
    //        )
    //
    //)
}

中間テーブルに格納されている作成日と更新日を取得できている事が確認できます。タイムスタンプに関しては、自動的に Carbon オブジェクトとして返却されるので、適宜フォーマットしています。

タイムスタンプカラム

created_at と updated_at カラム限定ですが、これらを取得したい場合はモデルに withTimestamps() を指定すると有効になります。

return $this->belongsToMany('App\Models\Tag')->withTimestamps();

pivot 名の変更

データ取得時に pivot プロパティへアクセスしますが、アクセスする pivot 名の変更もできます。

return $this->belongsToMany('App\Models\Tag')
    ->as('cng_name') // アクセスするpivot名の変更
    ->withPivot('id', 'created_at', 'updated_at');

モデルクラスの定義で as() メソッドをチェーンさせ、変更したいプロパティ名をセットする事で変更できます。取得時は変更した pivot 名でアクセスできます。

$data['pivot'] = [
    'id' => $t->cng_name->id,
    'created_at' => $t->cng_name->created_at->format('Y-m-d H:i:s'),
    'updated_at' => $t->cng_name->updated_at->format('Y-m-d H:i:s')
];

フィルタリング

多対多のリレーションを行う際に、中間テーブルを通して取得するデータをフィルタリングする事が出来ます。

// post_id が 3 のタグ情報を取得する
return $this->belongsToMany('App\Models\Tag')
    ->wherePivot('post_id', 3)
    ->withTimestamps();

// post_id が 1 と 3 のタグ情報を取得する
return $this->belongsToMany('App\Models\Tag')
    ->wherePivotIn('post_id', [1, 3])
    ->withTimestamps();

例として前者の、wherePivot() 指定でのデータ取得を行います。

laravel/app/Http/Controllers/SampleController.php
public function index()
{
    $posts = Post::all();
    $i = 0;
    foreach ($posts as $post) {
        $data[$i]['post'] = $post->post;
        if (!empty($post->tags)) {
            foreach ($post->tags as $tag) {
                $data[$i]['tag'] = $tag->tag;
                $data[$i]['pivot'] = [
                    'created_at' => $tag->pivot->created_at->format('Y-m-d H:i:s'),
                    'updated_at' => $tag->pivot->updated_at->format('Y-m-d H:i:s')
                ];
            }
        }
        $i++;
    }
}

結果は以下になります。

Array
(
    [0] => Array
        (
            [post] => post 01
        )

    [1] => Array
        (
            [post] => post 02
        )

    [2] => Array
        (
            [post] => post 03
            [tag] => fuga
            [pivot] => Array
                (
                    [created_at] => 2019-08-16 07:16:18
                    [updated_at] => 2019-08-16 07:16:18
                )

        )

    [3] => Array
        (
            [post] => post 04
        )

    [4] => Array
        (
            [post] => post 05
        )

    [5] => Array
        (
            [post] => post 06
        )

    [6] => Array
        (
            [post] => post 07
        )

    [7] => Array
        (
            [post] => post 08
        )

    [8] => Array
        (
            [post] => post 09
        )

    [9] => Array
        (
            [post] => post 10
        )
)

post_id が 3 のタグ情報のみが取得されている事が確認できます。

hasManyThrough

Laravel の Eloquent では、テーブルをまたぐリレーションも行えます。

「テーブルをまたぐリレーション」とは、例えば3つのテーブルを使ったリレーションです。

以下のような関係を持つテーブルがあるとします。

   members
  ↓↑    ↓↑
roles  posts

members レコードはそれぞれ、権限(Roles)と記事(Posts)を関連付けてデータを取得できる状況です。

モデルクラスは以下のように定義されています。

laravel/app/Models/Member.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Member extends Model
{
    public function posts()
    {
        return $this->hasMany('App\Models\Post');
    }

    public function role()
    {
        return $this->belongsTo('App\Models\Role');
    }
}
laravel/app/Models/Role.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Role extends Model
{
    public function members()
    {
        return $this->hasMany('App\Models\Member');
    }
}

コントローラでのデータ取得は以下のようになります。

laravel/app/Http/Controllers/SampleController.php
public function index()
{
    $members = Member::all();
    $data = [];
    foreach ($members as $member) {
        $data[] = [
            // membersテーブル
            'name' => $member->name,
            // rolesテーブル
            'role' => $member->role->name,
            // postsテーブル
            'posts' => array_map(function($post) {
                return $post['post'];
            }, $member->posts->toArray())
        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [name] => Merle Labadie
    //            [role] => Role03
    //            [posts] => Array
    //                (
    //                    [0] => post 01
    //                    [1] => post 11
    //                )
    //
    //        )
    //
    //    [1] => Array
    //        (
    //            [name] => Cullen Fisher
    //            [role] => Role01
    //            [posts] => Array
    //                (
    //                    [0] => post 02
    //                    [1] => post 12
    //                )
    //
    //        )
    //     .
    //     .
    //     .
    //     .
}

メンバーテーブルを軸に、権限・記事それぞれの該当情報を取得できている事が確認できます。

別方向からのリレーションアプローチ

先程とはまた別の方向からリレーションする事もできます。例えば、roles から posts を取得する。 roles テーブルから members テーブルを経由して posts テーブルを取得する。といった場合です。

   members
  ↓↑    ↓↑
roles  posts

モデルクラスに hasManyThrough() を定義します。

laravel/app/Models/Role.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Role extends Model
{
    public function posts()
    {
        return $this->hasManyThrough('App\Models\Post', 'App\Models\Member');
    }
}

第一引数にはリレーションを行うモデルを、第二引数には経由するモデルを指定します。

上記設定の場合では以下を暗黙的なルールとして処理を行います。

  • リレーション先テーブル「posts 」
  • 経由テーブル「members 」
  • roles テーブルの id と members テーブルの role_id を結合
  • members テーブルの id と Posts テーブルの member_id を結合

これを任意のものにしたい場合は以下のようにモデルを定義します。

return $this->hasManyThrough(
    'App\Models\Post',
    'App\Models\Member',
    'first_key',        // membersテーブルの外部キー
    'second_key',       // postsテーブルの外部キー
    'local_key',        // rolesテーブルのローカルキー
    'second_local_key'  // membersテーブルのローカルキー
);

データを取得します。

laravel/app/Http/Controllers/SampleController.php
public function index()
{
    $data = [];
    $roles = Role::all();
    foreach ($roles as $role) {
        $data[] = [
            'role' => $role->name,
            'posts' => array_map(function ($post) {
                return $post['post'];
            }, $role->posts->toArray())
        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [role] => Role01
    //            [posts] => Array
    //                (
    //                    [0] => post 02
    //                    [1] => post 12
    //                    [2] => post 07
    //                    [3] => post 17
    //                    [4] => post 10
    //                    [5] => post 20
    //                )
    //
    //        )
    //
    //    [1] => Array
    //        (
    //            [role] => Role02
    //            [posts] => Array
    //                (
    //                )
    //
    //        )
    //     .
    //     .
    //     .
    //     .
}

その権限を持つユーザが持つ記事を取得出来ている事が確認できました。

morphMany - ポリモーフィック

ポリモーフィック(Polymorphic=多様性)リレーションは、1つのテーブルで複数の関連するテーブルの管理を行う結合関係です。例えば、以下のような構造になります。

posts   feeds
 ↓↑      ↓↑
  comments

記事とフィード(つぶやきのようなもの)にコメントを付けられるとして、そのコメントを一つのテーブルで管理する。こんな関係です。

テーブルは以下のようになります。

// 記事テーブル
mysql> SHOW COLUMNS FROM `posts`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| member_id  | int(10) unsigned | NO   | MUL | NULL    |                |
| post       | text             | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

// フィード(つぶやき)テーブル
mysql> SHOW COLUMNS FROM `feeds`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| body       | varchar(255)     | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

// (記事&フィードの)コメントテーブル
mysql> SHOW COLUMNS FROM `comments`;
+------------------+------------------+------+-----+---------+----------------+
| Field            | Type             | Null | Key | Default | Extra          |
+------------------+------------------+------+-----+---------+----------------+
| id               | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| member_id        | int(10) unsigned | NO   | MUL | NULL    |                |
| comment          | text             | NO   |     | NULL    |                |
| commentable_id   | int(10) unsigned | NO   |     | NULL    |                |
| commentable_type | varchar(255)     | NO   |     | NULL    |                |
| created_at       | timestamp        | YES  |     | NULL    |                |
| updated_at       | timestamp        | YES  |     | NULL    |                |
+------------------+------------------+------+-----+---------+----------------+

まずはモデルにポリモーフィックを定義します。主たるモデルクラスに morphMany() で定義します。

laravel/app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    public function comments()
    {
        return $this->morphMany('App\Models\Comment', 'commentable');
    }
}
laravel/app/Models/Feed.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Feed extends Model
{
    public function comments()
    {
        return $this->morphMany('App\Models\Comment', 'commentable');
    }
}

2つのモデルで morphMany() メソッドに同じ引数を渡しています。第一引数にはコメントを保持するモデル、第二引数には id と type を紐づける Prefix(プレフィックス)を設定しています。

つまり、上記で commentable と設定した事で、記事とフィードの id を commentable_id カラムと結び付け、記事なのかフィードなのかを区別する為に commentable_type カラムを見る。という事になります。

これを任意のもので設定したい場合は以下のように記述します。

return $this->morphMany(
    'App\Models\Comment',
    'name',
    'type',
    'id',
    'local_key'
);
  • 第一引数 - 結合先モデル
  • 第二引数 - プレフィックス
  • 第三引数 - type としての識別カラム
  • 第四引数 - 外部キー
  • 第五引数 - ローカルキー

具体的に、今回の例で記述すると以下になります。

return $this->morphMany(
    'App\Models\Comments',
    null,
    'commentable_type',
    'commentable_id',
    'id'
);

第二引数に関しては、第三・四引数で個別に指定しているので未指定で OK です。

データ取得を行います。

laravel/app/Http/Controllers/SampleController.php
public function index()
{
    $posts = Post::all();
    $data = [];
    foreach ($posts as $post) {
        $data[] = [
            // postsテーブル
            'id' => $post->id,
            'post' => $post->post,
            // commentsテーブル
            'comments' => array_map(function ($comment) {
                return $comment['comment'];
            }, $post->comments->toArray())
        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [id] => 1
    //            [post] => post 01
    //            [comments] => Array
    //                (
    //                    [0] => Post Comment 013
    //                    [1] => Post Comment 024
    //                    [2] => Post Comment 053
    //                    [3] => Post Comment 066
    //                    [4] => Post Comment 094
    //                )
    //        )
    //    [1] => Array
    //        (
    //            [id] => 2
    //            [post] => post 02
    //            [comments] => Array
    //                (
    //                    [0] => Post Comment 015
    //                    [1] => Post Comment 021
    //                    [2] => Post Comment 030
    //                    [3] => Post Comment 093
    //                )
    //        )
    //     .
    //     .
    //     .
    //     .

}

記事に対するコメントが取得出来ている事が確認できます。 Feed の方も取得してみます。

laravel/app/Http/Controllers/SampleController.php
public function index()
{
    /*
     * morphMany - ポリモーフィック
     * feed comment
     */
    $feeds = Feed::all();
    $data = [];
    foreach ($feeds as $feed) {
        $data[] = [
            // postsテーブル
            'id' => $feed->id,
            'feed' => $feed->body,
            // commentsテーブル
            'comments' => array_map(function ($comment) {
                return $comment['comment'];
            }, $feed->comments->toArray())
        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [id] => 1
    //            [feed] =>
    //            [comments] => Array
    //                (
    //                    [0] => Feed Comment 028
    //                    [1] => Feed Comment 086
    //                    [2] => Feed Comment 089
    //                )
    //        )
    //    [1] => Array
    //        (
    //            [id] => 2
    //            [feed] =>
    //            [comments] => Array
    //                (
    //                    [0] => Feed Comment 005
    //                    [1] => Feed Comment 008
    //                )
    //        )
    //     .
    //     .
    //     .
    //     .

}

フィードに対するコメントが取得できている事が確認できます。

格納するモデルタイプの値

morphMany でのポリモーフィックリレーションを行う場合には判別用に共通して使用しているテーブルにモデルを格納しますが、ここでポイントなのが、格納するデータの書式です。

今回の例でいうと、posts や feeds テーブルのレコードは id によって結合されるのでいつも通りですが、ポリモーフィックを判別する comments テーブルの type カラム(commentable_type)にはモデル名を格納する必要があります。

格納する値は名前空間+モデル名の文字列で格納します。

mysql> SELECT * FROM `comments`;
+-----+-----------+------------------+----------------+------------------+
| id  | member_id | comment          | commentable_id | commentable_type | 
+-----+-----------+------------------+----------------+------------------+
|   1 |         5 | Post Comment 000 |              4 | App\Models\Post  |
|   2 |         2 | Feed Comment 001 |             10 | App\Models\Feed  |
|   3 |         1 | Post Comment 002 |              9 | App\Models\Post  |
|   4 |         2 | Post Comment 003 |              6 | App\Models\Post  |
|   5 |         5 | Feed Comment 004 |              4 | App\Models\Feed  |
|   6 |         5 | Feed Comment 005 |              2 | App\Models\Feed  |
|   7 |         2 | Post Comment 006 |              8 | App\Models\Post  |
|   8 |         8 | Post Comment 007 |              5 | App\Models\Post  |
|   9 |         6 | Feed Comment 008 |              2 | App\Models\Feed  |
|  10 |        10 | Post Comment 009 |              7 | App\Models\Post  |
+-----+-----------+------------------+----------------+------------------+

名前空間まで付与した形の文字列を格納しないと判別が行えず、狙ったデータを取得する事ができないので注意が必要です。

morphToMany - 多対多ポリモーフィック

ポリモーフィックリレーションにさらに多対多の関係性を加えた結合モデルです。

例えば、記事とフィードのテーブルが、共通のタグ情報を持つテーブルとそれぞれ結合する場合などです。モデルの関係は以下のようになります。

posts feeds
  ↓↑    ↓↑
 taggables
     ↓↑
    tags

これらのテーブルの構造は以下になります。

//  記事テーブル
mysql> SHOW COLUMNS FROM `posts`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| member_id  | int(10) unsigned | NO   | MUL | NULL    |                |
| post       | text             | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

// つぶやきテーブル
mysql> SHOW COLUMNS FROM `feeds`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| body       | varchar(255)     | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

// タグテーブル
mysql> SHOW COLUMNS FROM `tags`;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| tag        | varchar(255)     | NO   |     | NULL    |                |
| created_at | timestamp        | YES  |     | NULL    |                |
| updated_at | timestamp        | YES  |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+

// 中間テーブル
mysql> SHOW COLUMNS FROM `taggables`;
+---------------+------------------+------+-----+---------+-------+
| Field         | Type             | Null | Key | Default | Extra |
+---------------+------------------+------+-----+---------+-------+
| tag_id        | int(10) unsigned | NO   |     | NULL    |       |
| taggable_id   | int(10) unsigned | NO   |     | NULL    |       |
| taggable_type | varchar(255)     | NO   |     | NULL    |       |
| created_at    | timestamp        | YES  |     | NULL    |       |
| updated_at    | timestamp        | YES  |     | NULL    |       |
+---------------+------------------+------+-----+---------+-------+

多対多ポリモーフィック結合を設定するには、モデルへ morphToMany() メソッドで定義します。

laravel/app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    public function tags()
    {
        return $this->morphToMany('App\Models\Tag', 'taggable');
    }
}
laravel/app/Models/Feed.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Feed extends Model
{
    public function tags()
    {
        return $this->morphToMany('App\Models\Tag', 'taggable');
    }
}

2つのモデル共、共通な設定になりますが、第一引数に結合対象であるモデルを、第二引数には id と type のプレフィックスを渡します。

全てを任意の値で設定する場合は以下のように記述します。

return $this->morphToMany(
    'App\Models\Tag',
    'type_prefix',
    'table_name',
    'foreign_pivot_key',
    'related_pivot_key',
    'parent_key',
    'related_key',
    false
);
  • 第一引数 - 結合先モデル
  • 第二引数 - 中間テーブル type カラムのプレフィックス
  • 第三引数 - 中間テーブル名
  • 第四引数 - 結合元モデルと結合させる中間テーブルのカラム
  • 第五引数 - 結合先モデルと結合させる中間テーブルのカラム
  • 第六引数 - 結合元モデルの結合カラム
  • 第七引数 - 結合先モデルの結合カラム
  • 第八引数 - リレーションの逆を接続しているか。設定は bool 値。デフォルトは false

今回の例では以下のようになります。

return $this->morphToMany(
    'App\Models\Tag',
    'taggable',
    'taggables',
    'taggable_id',
    'tags_id',
    'id',
    'id',
    false
);

データを取得します。

laravel/app/Http/Controllers/SampleController.php
public function index()
{
    $posts = Post::all();
    $data = [];
    foreach ($posts as $post) {
        $data[] = [
            // postsテーブル
            'id' => $post->id,
            'post' => $post->post,
            // tagsテーブル
            'tags' => array_map(function ($tag) {
                return $tag['tag'];
            }, $post->tags->toArray())
        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [id] => 1
    //            [post] => post 01
    //            [tags] => Array
    //                (
    //                    [0] => tag 01
    //                    [1] => tag 07
    //                    [2] => tag 03
    //                    [3] => tag 02
    //                )
    //        )
    //    [1] => Array
    //        (
    //            [id] => 2
    //            [post] => post 02
    //            [tags] => Array
    //                (
    //                    [0] => tag 05
    //                )
    //        )
    //     .
    //     .
    //     .
    //     .
    
}

記事に対するタグ情報を取得出来ている事が確認できます。フィードの方も取得します。

public function index()
{
    $feeds = Feed::all();
    $data = [];
    foreach ($feeds as $feed) {
        $data[] = [
            // feedsテーブル
            'id' => $feed->id,
            'feed' => $feed->body,
            // tagsテーブル
            'tags' => array_map(function ($tag) {
                return $tag['tag'];
            }, $feed->tags->toArray())
        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [id] => 1
    //            [feed] => Est saepe tenetur sunt inventore.
    //            [tags] => Array
    //                (
    //                    [0] => tag 02
    //                )
    //        )
    //    [1] => Array
    //        (
    //            [id] => 2
    //            [feed] => Eum dolorem voluptatem quia dignissimos eum.
    //            [tags] => Array
    //                (
    //                    [0] => tag 08
    //                    [1] => tag 04
    //                )
    //        )
    //     .
    //     .
    //     .
    //     .

}

フィードに対するタグ情報を取得出来ている事が確認できました。

morphedByMany

多対多ポリモーフィックの逆からの取得、つまりは Tag モデルを主軸として、そのタグに含まれている記事やフィードを取得する事も出来ます。この場合はモデルクラスへ morphedByMany() メソッドで定義します。

laravel/app/Models/Tag.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    public function posts()
    {
        return $this->morphedByMany('App\Models\Post', 'taggable');
    }

    public function feeds()
    {
        return $this->morphedByMany('App\Models\Feed', 'taggable');
    }
}

取得します。

laravel/app/Http/Controllers/SampleController.php
public function index()
{
    $tags = Tag::all();
    $data = [];
    foreach ($tags as $tag) {
        $data[] = [
            'tag' => $tag->tag,
            'posts' => array_map(function ($post) {
                return $post['post'];
            }, $tag->posts->toArray()),
            'feeds' => array_map(function ($feed) {
                return $feed['body'];
            }, $tag->feeds->toArray())

        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [tag] => tag 01
    //            [posts] => Array
    //                (
    //                    [0] => post 01
    //                    [1] => post 19
    //                )
    //            [feeds] => Array
    //                (
    //                    [0] => Et itaque totam non laudantium.
    //                    [1] => Aperiam nobis incidunt commodi minus.
    //                    [2] => Et itaque totam non laudantium.
    //                    [3] => Suscipit omnis et aut et reprehenderit.
    //                    [4] => Iure voluptatem et explicabo deserunt eligendi.
    //                )
    //        )
    //    [1] => Array
    //        (
    //            [tag] => tag 02
    //            [posts] => Array
    //                (
    //                    [0] => post 05
    //                    [1] => post 09
    //                    [2] => post 01
    //                )
    //            [feeds] => Array
    //                (
    //                    [0] => Est saepe tenetur sunt inventore.
    //                    [1] => Officia ut corrupti accusamus.
    //                    [2] => Est molestias quidem assumenda est.
    //                    [3] => Quae ut debitis distinctio et ipsum culpa.
    //                    [4] => Laborum quis voluptate et sed aut ipsa.
    //                )
    //        )
    //     .
    //     .
    //     .
    //     .

}

タグを主としてそれに属する記事とフィードを取得できている事が確認できました。

リレーションのクエリ

Eloquent では、リレーションしたモデルに対して制約を付けてデータを取得できます。

has()

has() メソッドを使うと、リレーション先の値を持つ主データを取得できます。

// 記事を持つメンバーのみを取得する
$members = Member::has('posts')->get();

件数などを細かく指定し取得も行えます。第二引数に演算子、第三引数に指定数を渡しています。

// 記事を3件以上持つメンバーのみを取得する
$members = Member::has('posts', '>=', 3)->get();

whereHas() / orWhereHas()

複雑な制約を付けたい場合は whereHas() メソッドを使い、クロージャを併用して条件を定義できます。

// 文字列「hoge」を含む記事を持つメンバーを取得する
$members = Member::whereHas('posts', function ($query) {
    $query->where('post', 'like', '%hoge%');
})->get();

doesntHave() / orDoesntHave()

制約には「有する」だけでなく「持っていない」場合のクエリも用意されています。その時は doesntHave() メソッドを使います。

// 記事を持たないメンバーを取得する
$members = Member::doesntHave('posts')->get();

whereDoesntHave() / orWhereDoesntHave()

has() と同様より複雑な条件式を定義するなら、whereDoesntHave() を用います。第二引数にクロージャを渡し、where 句を付与できます。

// 文字列「hoge 」を含む記事を持たないメンバーを取得する
$members = Member::whereDoesntHave('posts', function ($query) {
    $query->where('post', 'like', '%hoge%');
})->get();

withCount()

結合先モデルの件数も withCount() メソッドを用いる事で取得できます。

public function index()
{
    // 記事の件数も取得する
    $members = Member::withCount('posts')->get();

    $data = [];
    foreach ($members as $member) {
        $data[] = [
            'id' => $member->id,
            'count' => $member->posts_count, // 件数へアクセス
            'post' => array_map(function ($post) {
                return $post['post'];
            }, $member->posts->toArray())

        ];
    }

    // print_r($data);
    // => Array
    //(
    //    [0] => Array
    //        (
    //            [id] => 1
    //            [count] => 6
    //            [post] => Array
    //                (
    //                    [0] => post 15 buzz
    //                    [1] => post 19 fizz
    //                    [2] => post 20 hoge
    //                    [3] => post 23 fizz
    //                    [4] => post 24 buzz
    //                    [5] => post 26 buzz
    //                )
    //        )
    //    [1] => Array
    //        (
    //            [id] => 2
    //            [count] => 7
    //            [post] => Array
    //                (
    //                    [0] => post 06 hoge
    //                    [1] => post 09 fizz
    //                    [2] => post 10 fizz
    //                    [3] => post 13 fizz
    //                    [4] => post 21 fizz
    //                    [5] => post 25 buzz
    //                    [6] => post 30 buzz
    //                )
    //        )
    //     .
    //     .
    //     .
    //     .

}

上記のように、件数は {リレーション名}_count として取得できます。

Eager ロード

O/R マッパーの最大の弱点に「N+1 問題」があります。 データ量N+1回の SQL クエリが ORM によって走ってしまう事を指しますが、 これは単純にリレーションしたとしても値にアクセスした時点でデータを取得しに行く仕様からきているものです。

この記事でも何度も登場している、Eloquent でデータを取得後にループで回して値にアクセスしている部分は、その数だけクエリが発行されてることになります。

この、N+1 問題を防ぐ為に関連するデータを一括取得を行うのが Eager ロードになります。

Eager ロードを行うには、with() メソッドを使います。

// posts テーブルを Eager ロード
$members = Member::with('posts')->get();

リレーションしているモデルが複数ある場合は配列で渡します。

$members = Member::with(['posts', 'role'])->get();

まとめ

ORM でのリレーションは、1 対 1 など、モデル同士の関係を定義し結合を行っていくので、それぞれの関係を理解するまではなかなか飲み込みずらい部分もあります。

ただし、Laravel で提供されている Eloquent に限らず PHP フレームワークで提供されている ORM では同じ考えのもとにモデル定義を行っている事も多いので、1回理解してしまえば広く使えるようになります。

サンプルコード

Author

rito

  • Backend Engineer
  • Tokyo, Japan
  • PHP 5 技術者認定上級試験 認定者
  • 統計検定 3 級