RitoLabo

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

  • 公開:
  • 更新:
  • カテゴリ: PHP Laravel
  • タグ: PHP,Laravel,EloquentORM,Model,Relationships,Polymorphic,EagerLoading

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

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

アジェンダ
  1. 開発環境
  2. リレーション
  3. hasOne - 1対1
  4. belongsTo - 1対1
  5. hasMany - 1対多
  6. belongsTo - 多対1
  7. belongsToMany - 多対多
    1. 中間テーブル情報を除外してデータ取得
    2. 中間テーブルのフィールド取得
      1. タイムスタンプカラム
      2. pivot名の変更
      3. フィルタリング
  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ロード

開発環境

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

  • Linux CentOS 7
  • Apache 2.4
  • 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');
}
}

これは「多対1」とも呼ばれます。

コントローラからデータを取得します。

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テーブルのidとpost_tagテーブルのpost_idが結合する
  4. tagsテーブルのidとpost_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回理解してしまえば広く使えるようになります。

サンプルコード