L初めてのLaravel

Eloquent リレーション図解

テーブルのつながりを、図で一気につかむ

Eloquentのリレーションは「どのテーブルが外部キーを持ち、どちらを指すか」が分かると一気にラクになります。 ここでは各リレーションを関係の図(箱と矢印)・モデル定義コード・使い方コード・具体例の4点セットで解説します。最後に、必ずつまずくN+1問題と Eager Loadingもまとめました。

主となるモデル(親)関連するモデル(子)中間テーブル / 中間モデル外部キー(FK)

hasOne / belongsTo(1対1)

hasOne()belongsTo()

1つのモデルが、別の1つのモデルとちょうど1対1で結びつく関係。「ユーザーは1つのプロフィールを持つ」など。

いつ使うか

1件に対して関連データがちょうど1件だけのとき。テーブルを分けて主テーブルを軽くしたい・任意情報を切り出したいときに使います(例: users と profiles)。

外部キーの向き

「持たれる側(子)」が外部キーを持ちます。profiles テーブルに user_id を置き、profiles → users を指します。親(User)側が hasOne、子(Profile)側が belongsTo です。

関係の図

users

User

  • id
  • name

profiles

Profile

  • id
  • user_id→ users.id
  • bio
  • profiles.user_idusers.id1 — 1— 外部キー user_id は子(profiles)側にあり、親(users)を指す

モデル定義コード

親: User モデル(hasOne)

class User extends Model
{
    // ユーザーは1つのプロフィールを持つ
    public function profile()
    {
        return $this->hasOne(Profile::class);
        // 外部キーを明示するなら:
        // return $this->hasOne(Profile::class, 'user_id', 'id');
    }
}

子: Profile モデル(belongsTo = 逆向き)

class Profile extends Model
{
    // プロフィールは1人のユーザーに属する
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

使い方コード

親から子をたどる

$user = User::find(1);
$bio = $user->profile->bio;   // hasOne 経由でプロフィール取得

子から親をたどる(逆)

$profile = Profile::find(1);
$name = $profile->user->name; // belongsTo 経由でユーザー取得

具体例

会員サイトで、ログイン情報を持つ users テーブルと、自己紹介や住所などを持つ profiles テーブルを分けるケース。1ユーザーにプロフィールは1つだけなので hasOne / belongsTo が最適です。

hasMany / belongsTo(1対多)

hasMany()belongsTo()

1つの親が複数の子を持つ、最もよく使う関係。「ユーザーは複数の投稿を持つ」「投稿は1人のユーザーに属する」。

いつ使うか

1件に対して関連データが何件もぶら下がるとき。ブログ記事・コメント・注文明細など、実務で圧倒的に登場する定番です。

外部キーの向き

「多」側(子)が外部キーを持ちます。posts テーブルに user_id を置き、posts → users を指します。親(User)が hasMany、子(Post)が belongsTo です。

関係の図

users

User

  • id
  • name

posts

Post

  • id
  • user_id→ users.id
  • title
  • posts.user_idusers.id1 — 多— 外部キー user_id は「多」側(posts)にあり、親(users)を指す

モデル定義コード

親: User モデル(hasMany)

class User extends Model
{
    // ユーザーは複数の投稿を持つ
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

子: Post モデル(belongsTo = 逆向き)

class Post extends Model
{
    // 投稿は1人のユーザーに属する
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

使い方コード

親から子の一覧をたどる

$user = User::find(1);

foreach ($user->posts as $post) {   // Collection(複数)が返る
    echo $post->title;
}

$count = $user->posts()->count();   // メソッド呼び出しで件数だけ取得

子から親をたどる / 子を作成

$post = Post::find(10);
echo $post->user->name;             // 投稿者の名前

// 親経由で子を作成すると user_id が自動でセットされる
$user->posts()->create(['title' => '初投稿']);

具体例

ブログで「1人のユーザーが何本も記事を書く」関係。User::find(1)->posts でその人の全記事、Post::find(10)->user で記事の著者をたどれます。外部キー user_id は記事(多い側)に置きます。

belongsToMany(多対多・中間テーブル / pivot)

belongsToMany()

両側が複数を持ち合う関係。「記事は複数のタグを持ち、タグも複数の記事に付く」。間に中間テーブル(pivot)を挟みます。

いつ使うか

どちらから見ても相手が複数になるとき。記事とタグ、ユーザーと役割(ロール)、生徒と講座など。組み合わせを記録する専用テーブルが必要です。

外部キーの向き

外部キーは両側のどちらでもなく、中間テーブル(post_tag)が両方への外部キー(post_id と tag_id)を持ちます。中間テーブル名は原則、関連する2つの単数形をアルファベット順に _ でつなぎます(post + tag → post_tag)。

関係の図

posts

Post

  • id
  • title

post_tag

(中間テーブル)

  • post_id→ posts.id
  • tag_id→ tags.id

tags

Tag

  • id
  • name
  • post_tag.post_idposts.id多 — 多— 中間テーブルが post_id で posts を指す
  • post_tag.tag_idtags.id多 — 多— 中間テーブルが tag_id で tags を指す

モデル定義コード

両側に belongsToMany を書く

class Post extends Model
{
    public function tags()
    {
        // 中間テーブル post_tag を介して多対多
        return $this->belongsToMany(Tag::class);
    }
}

class Tag extends Model
{
    public function posts()
    {
        return $this->belongsToMany(Post::class);
    }
}

使い方コード

たどる / 付け外し

$post = Post::find(1);

foreach ($post->tags as $tag) {   // 紐づく全タグ
    echo $tag->name;
}

$post->tags()->attach($tagId);    // 関連を1件追加(中間に行を作る)
$post->tags()->detach($tagId);    // 関連を1件削除
$post->tags()->sync([1, 2, 3]);   // この3つだけに一括で揃える

中間テーブルの追加情報(pivot)

// マイグレーションで中間テーブルに列を足しておくと…
return $this->belongsToMany(Tag::class)->withPivot('created_by')->withTimestamps();

foreach ($post->tags as $tag) {
    echo $tag->pivot->created_by;   // 中間テーブルの値に $tag->pivot でアクセス
}

具体例

記事にタグを付ける機能。1記事に複数タグ、1タグは複数記事に付くので belongsToMany。attach/detach/sync で紐付けを操作します。「誰が付けたか」など関係そのものの情報は中間テーブルに列を足し、pivot でアクセスします。

hasManyThrough / hasOneThrough(中間モデル経由)

hasManyThrough()hasOneThrough()

間に別のモデルを1つ挟んで、離れたモデルへ一気にたどる関係。「国 → ユーザー → 投稿」を、国から投稿へ直接。

いつ使うか

A→B→C と2段でつながっているとき、中間のBを毎回書かずにAからCへ直接アクセスしたい場合。多対多とは違い、間にあるのは中間「モデル(テーブル)」で、pivotではありません。

外部キーの向き

外部キーは通常の1対多と同じく、下流のテーブルが上流を指します。users が country_id を持ち、posts が user_id を持つ。Through は「その2本の外部キーを連鎖でたどる」ショートカットです。

関係の図

countries

Country

  • id
  • name

users

User(中間)

  • id
  • country_id→ countries.id

posts

Post

  • id
  • user_id→ users.id
  • title
  • users.country_idcountries.id1 — 多— 中間 users が countries を指す
  • posts.user_idusers.id1 — 多— posts が中間 users を指す(この2本を連鎖してたどる)

モデル定義コード

Country モデル(User を経由して Post へ)

class Country extends Model
{
    // 国 → (users を経由) → その国のユーザーの全投稿
    public function posts()
    {
        return $this->hasManyThrough(
            Post::class,     // 最終的に欲しいモデル
            User::class      // 中間のモデル
            // 省略時: users.country_id と posts.user_id を推測して連結
        );
    }
}

使い方コード

国から一気に投稿を取得

$country = Country::find(1);

foreach ($country->posts as $post) {
    // その国に属する全ユーザーの投稿が、User を書かずに取れる
    echo $post->title;
}

具体例

「日本のユーザーが書いた投稿を全部集めたい」ようなケース。本来は 国→ユーザー→投稿 と2段たどる必要がありますが、hasManyThrough なら Country::find(1)->posts で一気に取得できます。1件だけ欲しいときは hasOneThrough を使います。

ポリモーフィック(morphOne / morphMany / morphTo / morphToMany)

morphOne()morphMany()morphTo()morphToMany()

1つの子テーブルが、複数種類の親に属せる関係。「コメントが記事にも動画にも付く」を、テーブルを増やさず1つでまかないます。

いつ使うか

同じ子(コメント・画像・いいね など)を、複数の異なる親モデルにぶら下げたいとき。親ごとに comments テーブルを分けずに済みます。

外部キーの向き

子テーブルが「相手のID(commentable_id)」と「相手の種類(commentable_type)」の2列を持ちます。type にモデルのクラス名(例: App\\Models\\Post)が入るので、同じ外部キーで複数の親を指し分けられます。親側が morphMany/morphOne、子側が morphTo です。

関係の図

posts

Post

  • id
  • title

videos

Video

  • id
  • url

comments

Comment

  • id
  • body
  • commentable_id相手のID
  • commentable_type相手のクラス名
  • comments.commentable_*posts.id多 — 1— type が Post なら posts を指す
  • comments.commentable_*videos.id多 — 1— type が Video なら videos を指す(1つのFKで指し分け)

モデル定義コード

子: Comment(morphTo)

class Comment extends Model
{
    // このコメントが「何に」付いているか(Post か Video か…)
    public function commentable()
    {
        return $this->morphTo();
        // commentable_id と commentable_type の2列を使う
    }
}

親: Post / Video(morphMany)

class Post extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Video extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

使い方コード

親からコメントをたどる / 子から親をたどる

$post = Post::find(1);
foreach ($post->comments as $c) {   // 記事のコメント
    echo $c->body;
}

$comment = Comment::find(5);
$parent = $comment->commentable;    // Post か Video が返る(実行時に決まる)

// 追加も同じ書き方で親の種類を問わない
$video->comments()->create(['body' => '面白い!']);

多対多版: morphToMany(タグを何にでも付ける)

class Post extends Model
{
    // タグを Post にも Video にも付けられる多対多ポリモーフィック
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}
// 中間テーブル taggables(tag_id, taggable_id, taggable_type)を使う

具体例

コメント機能を記事(Post)にも動画(Video)にも付けたいケース。comments テーブルを1つだけ作り、commentable_id と commentable_type の2列で「どの種類の・どのレコードに」付くかを記録します。morphToMany を使えば、タグのような多対多も同じ考え方で複数の親に共有できます。

N+1問題 と Eager Loading(with())

リレーションは便利ですが、うっかり書くと「無駄なSQLが大量に飛ぶ」N+1問題を起こします。リレーションを学んだら必ずセットで覚えたい、最重要の注意点です。

何が問題か

一覧(N件)をループしながら、各件で関連データを $post->user のように取り出すと、1件ごとに追加のSQLが飛びます。最初の一覧取得1回 + N件ぶんのN回 = 合計 N+1 回のクエリになる、これがN+1問題です。件数が増えるほど遅くなります。

解決策

あらかじめ with('リレーション名') で関連データをまとめて先読み(Eager Loading)します。Laravelは関連を IN 句でまとめて取得するので、クエリは合計2回(一覧1回 + 関連1回)で済みます。

悪い例: N+1が発生する

$posts = Post::all();              // クエリ1回

foreach ($posts as $post) {
    echo $post->user->name;        // 1件ごとに users への追加クエリ!
}
// posts が100件なら 1 + 100 = 101 回のクエリ

ループの中で $post->user を初めて触るたびに、その都度SQLが実行されてしまいます。

良い例: with() で先読み

$posts = Post::with('user')->get();   // 一覧1回 + users まとめて1回 = 2回

foreach ($posts as $post) {
    echo $post->user->name;           // 追加クエリは飛ばない
}

// 複数・ネストもまとめて指定できる
Post::with(['user', 'comments.user'])->get();

with() で「これから使う関連」を宣言しておくと、Laravelがまとめて取得してくれます。件数が増えてもクエリ回数は一定です。

  • 遅延読み込みでも load('user') で後から先読みできます(取得済みのモデルに関連を足す)。
  • 件数だけ欲しいなら withCount('comments') を使うと $post->comments_count で取れ、これもN+1を防げます。
  • 開発中は Laravel Debugbar や DB::listen でクエリ数を見張ると、N+1に気づきやすくなります。

モデルの中身は全体マップの「Eloquentモデルクラスの中身」ツリーも参考になります。用語は実務用語集でも確認できます。