【Laravel】Eloquentのリレーション完全ガイド|hasOne・hasMany・belongsTo・belongsToMany

Laravel の Eloquent ORM(オブジェクトとテーブルを対応づける仕組み)では、テーブル間の関係を「リレーション」として PHP のメソッドで表現します。SQL の JOIN(複数テーブルを結合して取得するクエリ)を直接書かなくても、モデルのメソッドを通じてリレーション先のデータを取得できるため、コードの見通しがよくなります。本記事では hasOnehasManybelongsTobelongsToMany の 4 種類を中心に、定義方法・取得方法・よくある失敗を整理します。

動作の細部はバージョンによって異なる場合があります。コード例は執筆時点で Laravel 10 を主な参照先とします。手元の composer.jsonlaravel/framework に合わせ、公式ドキュメントの Eloquent: Relationships 章 を並行して参照することをおすすめします。

はじめに

「ユーザーとプロフィールを一緒に取得したい」「投稿に紐づくコメントを一覧で表示したい」という要件は、Laravel アプリケーションで日常的に発生します。テーブルをまたいだデータ取得を毎回手書きの SQL で行うと、コードが冗長になりがちです。

Eloquent のリレーションを使うと、「User モデルは Profile を 1 つ持つ」「Post モデルは複数の Comment を持つ」といった関係をモデルのメソッドとして定義できます。定義後は $user->profile$post->comments のような直感的な記述でアクセスできます。

この記事では次の範囲を扱います。

  • 4 種のリレーション(hasOne / hasMany / belongsTo / belongsToMany)の定義と使い方
  • Eager Loading による N+1 問題の回避
  • belongsToMany で使う中間テーブル(ピボットテーブル)の操作
  • よくある失敗例と対処

背景:4 種のリレーションの概要

メソッド意味
hasOne1 対 1(親側)User → Profile
hasMany1 対 多(親側)User → Post
belongsTo1 対 1 / 1 対多(子側)Post → User
belongsToMany多対多Post ↔ Tag

外部キーの命名規則は、デフォルトで「呼び出し元モデルのスネークケース名(単語をアンダースコアでつなぐ形式)+ _id」です。たとえば User モデルの hasMany(Post::class) は、posts テーブルの user_id カラムを外部キーとして使います。命名が異なる場合は第 2 引数で明示します。

実装例

hasOne:1 対 1 リレーション

hasOne は「1 つのモデルが 1 つの関連モデルを持つ」関係を表します。User と Profile の例で説明します。

profiles テーブルには user_id カラムが必要です。

// app/Models/User.php
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;

public function profile(): HasOne
{
    return $this->hasOne(Profile::class);
}

取得は次のように行います。

$user = User::find(1);
$profile = $user->profile; // Profile モデルまたは null

プロフィールが存在しない場合に空のモデルを返したいときは withDefault を使います。存在しないときの null チェックを減らせます。

public function profile(): HasOne
{
    return $this->hasOne(Profile::class)->withDefault();
}

withDefault はデフォルト値を配列で指定できます。詳細は 公式ドキュメントの Default Models を参照してください。

hasMany:1 対多リレーション

hasMany は「1 つのモデルが複数の関連モデルを持つ」関係を表します。User と Post の例を示します。

posts テーブルには user_id カラムが必要です。

// app/Models/User.php
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

取得すると Laravel の Collection(配列に似たオブジェクト)が返ります。

$user = User::find(1);
$posts = $user->posts; // Post モデルのコレクション

絞り込みはリレーションにメソッドチェーンで追加できます。$user->posts() でクエリビルダに切り替えてから where などの条件を追加します。

$publishedPosts = $user->posts()->where('status', 'published')->get();

belongsTo:逆リレーション(子側の定義)

belongsTohasOne / hasMany の逆側(外部キーを持つモデル)で定義します。Post → User の例です。

// app/Models/Post.php
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

外部キーのカラム名が規則と異なる場合(例: author_id)は第 2 引数で指定します。

public function user(): BelongsTo
{
    return $this->belongsTo(User::class, 'author_id');
}

取得は次のように行います。

$post = Post::find(1);
$user = $post->user; // User モデルまたは null

belongsToMany:多対多リレーション

belongsToMany は「互いに複数の関連を持つ」関係を表します。Post と Tag の例では、1 つの Post は複数の Tag を持ち、1 つの Tag も複数の Post に属せます。

中間テーブル(ピボットテーブル)が必要です。テーブル名はデフォルトで 2 モデル名をアルファベット順にアンダースコアでつないだ post_tag になります。中間テーブルのマイグレーションは database/migrations/ に追加します。

// app/Models/Post.php
use Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;

public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class);
}

// app/Models/Tag.php
public function posts(): BelongsToMany
{
    return $this->belongsToMany(Post::class);
}

中間テーブルのマイグレーション例です。

Schema::create('post_tag', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->primary(['post_id', 'tag_id']);
});

ピボットテーブルの操作

関連の追加・削除・同期は専用メソッドで行います。

$post = Post::find(1);

// タグを追加(重複しない)
$post->tags()->attach([1, 2, 3]);

// タグを削除
$post->tags()->detach([2]);

// 指定した ID だけが紐づいた状態にする(他は削除)
$post->tags()->sync([1, 3]);

ピボットテーブルに追加カラム(例: sort_order)がある場合は withPivot で指定します。

public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)->withPivot('sort_order');
}

// アクセス方法
foreach ($post->tags as $tag) {
    echo $tag->pivot->sort_order;
}

タイムスタンプを記録したい場合は withTimestamps を追加します。

return $this->belongsToMany(Tag::class)->withTimestamps();

Eager Loading で N+1 問題を回避する

リレーションを使う際に最も注意すべきなのが N+1 問題です。N+1 問題とは、N 件のデータをループで処理するときにリレーション先を都度取得し、最終的に N+1 回の SQL が発行されてしまうパターンです。次のコードはループのたびに SQL が実行されます。

// N+1 問題の例(posts の件数分 SQL が発行される)
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name; // ループごとに SELECT が走る
}

with を使った Eager Loading で、関連データを最初にまとめて取得できます。

// Eager Loading(SELECT は 2 回だけ)
$posts = Post::with('user')->get();
foreach ($posts as $post) {
    echo $post->user->name;
}

複数のリレーションを同時にロードできます。

$posts = Post::with(['user', 'tags'])->get();

すでに取得済みのコレクションにリレーションを後から読み込む場合は load を使います。

$posts = Post::all();
$posts->load('user');

常に Eager Loading したいリレーションはモデルの $with プロパティで指定します。

// app/Models/Post.php
protected $with = ['user'];

実装時の注意点

外部キーの命名規則を確認する

Eloquent は呼び出し元モデル名をスネークケースにして _id を付加した名前を外部キーとして推測します。User モデルなら user_id です。テーブル設計が規則と異なる場合は、リレーションメソッドの引数で明示的に指定してください。

belongsTo の定義忘れで null が返る

hasMany 側だけを定義して belongsTo 側を定義し忘れると、逆方向のアクセス($post->user など)で null が返ります。双方向アクセスが必要な場合は両モデルに定義が必要です。

ピボットテーブルのテーブル名と外部キー名

belongsToMany のピボットテーブル名とキー名が慣例と異なる場合は、第 2・第 3・第 4 引数で明示します。

return $this->belongsToMany(
    Tag::class,
    'article_tags',   // ピボットテーブル名
    'article_id',     // 自身の外部キー
    'label_id',       // 相手の外部キー
);

失敗例:Eager Loading を忘れて本番で大量の SQL が発行される

開発中は件数が少ないためリレーションアクセスのたびに SQL が走っても気づきにくいです。本番環境で件数が増えると急激にパフォーマンスが低下します。

// 1000 件の posts があれば SQL が 1001 回発行される
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name;
}

// with で 2 回に抑えられる
$posts = Post::with('user')->get();

Laravel Debugbar(リクエスト内のクエリ一覧を確認できる開発用ツール)などを導入して SQL 発行数を可視化すると、N+1 問題を早期に発見しやすくなります。

学び:リレーションで変わること

Eloquent のリレーションを正しく定義すると、コードベースが次のように整理されやすくなります。

データ取得ロジックがモデルにまとまる

コントローラや Service から JOIN を毎回書かずに、$user->posts$post->tags のような読みやすい表現でアクセスできます。

Eager Loading で SQL 発行を制御できる

with を使うことでクエリ数を明示的にコントロールでき、N+1 問題を構造的に防ぎやすくなります。

双方向アクセスが可能になる

hasManybelongsTo をセットで定義すると、双方向のアクセスが可能な設計になります。User 側からは Posts、Post 側からは User を参照できます。

まとめ

  • hasOne / hasMany は「外部キーを相手テーブルに持つ」関係を親側から定義する。
  • belongsTo は外部キーを持つモデル(子側)から親側へのアクセスを定義する。
  • belongsToMany には中間テーブルが必要で、attach / detach / sync で操作する。
  • N+1 問題は with による Eager Loading で回避する。

次に試せること

  1. UserPostComment のように 3 テーブルをまたいで with('posts.comments') でネストした Eager Loading を試す。
  2. whereHas を使ってリレーション先の条件でフィルタリングする(公式ドキュメントの Querying Relationship Existence)。
  3. hasManyThrough / hasOneThrough による中間テーブル越しのリレーションを把握する。

Source

LaravelLaravel

Posted by 千原 耕司