【Laravel】多対多リレーション(belongsToMany)を実装する

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回は hasManybelongsTo を使って、1対多のリレーションを扱いました。

今回は、Eloquentのリレーションの中でもつまずく人が多い「多対多(belongsToMany)」を扱います。

「1つの記事に複数のタグをつけたい」「1人のユーザーが複数のロールを持つ」など、実務でもよく出てくる形なので、ここでしっかり理解しておきましょう。

多対多リレーションとは?

hasMany・belongsToとの違い

多対多とは、片方のレコードが相手側の複数レコードと関連し、かつその逆も成り立つ関係のことです。

たとえば「記事とタグ」の関係を考えてみます。

  • 1つの記事には複数のタグをつけられる
  • 1つのタグは複数の記事に使われる

hasMany は「1人のユーザーが複数の投稿を持つ」のように、片方向だけが「複数」になる関係でした。

多対多は、両方向が「複数」になる点が決定的に違います。

中間テーブルが必要な理由

多対多では、articles テーブルに tag_id を1つだけ持たせる、という設計はできません。

1つの記事に複数のタグをつけたいのに、カラムは1つしか値を持てないからです。

そこで登場するのが中間テーブル(pivotテーブル)です。

記事とタグそれぞれのIDを1行ずつ記録する専用のテーブルを間に挟むことで、「どの記事にどのタグがついているか」を自由な組み合わせで表現できます。

articles テーブル          article_tag テーブル         tags テーブル
+----+-------+            +------------+--------+       +----+--------+
| id | title |            | article_id | tag_id |       | id | name   |
+----+-------+            +------------+--------+       +----+--------+
| 1  | 記事A |            | 1          | 1      |       | 1  | Laravel|
| 2  | 記事B |            | 1          | 2      |       | 2  | PHP    |
+----+-------+            | 2          | 2      |       +----+--------+
                           +------------+--------+

私が初めてこの構造を見たときは、「テーブルが3つも必要なの?」と身構えましたが、慣れると「関係だけを記録する専用の名簿がある」というだけのシンプルな話だと分かりました。

実装手順

手順1:マイグレーションで中間テーブルを作る

articles テーブル・tags テーブルに加えて、中間テーブル article_tag を作成します。

中間テーブルの名前は、関連する2つのテーブル名を単数形にしてアルファベット順に並べ、アンダースコアでつなぐのがLaravelの命名規則です。

php artisan make:migration create_article_tag_table
<?php
// database/migrations/xxxx_xx_xx_create_article_tag_table.php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('article_tag', function (Blueprint $table) {
            $table->id();
            $table->foreignId('article_id')->constrained()->cascadeOnDelete();
            $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('article_tag');
    }
};

foreignId('article_id')->constrained() で、articles テーブルの id を参照する外部キーを自動で設定できます。

cascadeOnDelete() をつけておくと、記事が削除されたときに中間テーブルの関連行も一緒に削除されるので、不要なデータが残りません。

手順2:モデルにbelongsToManyを定義する

Article モデルと Tag モデルの両方に、belongsToMany を使ってリレーションを定義します。

<?php
// app/Models/Article.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Article extends Model
{
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }
}
<?php
// app/Models/Tag.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Tag extends Model
{
    public function articles(): BelongsToMany
    {
        return $this->belongsToMany(Article::class);
    }
}

命名規則どおりにテーブルを作っていれば、belongsToMany(Tag::class) だけで中間テーブル名やキー名を自動で推測してくれます。

手順3:データを取得・登録する

リレーションを定義すると、次のように直感的にデータを扱えます。

<?php

use App\Models\Article;

// 記事に紐づくタグを取得
$article = Article::find(1);
foreach ($article->tags as $tag) {
    echo $tag->name;
}

// タグを1件だけ紐づける
$article->tags()->attach(3);

// 複数タグをまとめて紐づける
$article->tags()->attach([1, 2, 3]);

// タグの紐づけを解除する
$article->tags()->detach(2);

// 指定したタグの組み合わせに完全に置き換える
$article->tags()->sync([1, 4, 5]);

attach は追加、detach は削除、sync は「指定した配列の状態に完全に一致させる」動作をする点を覚えておくと、フォームのチェックボックスからタグを更新するような処理がとても書きやすくなります。

つまずきやすいポイント:中間テーブルへの追加データ

withPivotを忘れると値が取れない

タグをつけた日時や、記事内での表示順など、中間テーブル自体に追加のカラムを持たせたいケースがあります。

私が実際につまずいたのは、中間テーブルに sort_order カラムを追加したのに、Eloquentから値を取り出せなかったときです。

❌ Before:withPivotを書かずに中間テーブルのカラムを取ろうとする

<?php
// app/Models/Article.php

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

$article = Article::find(1);
foreach ($article->tags as $tag) {
    echo $tag->pivot->sort_order; // Undefined property エラーになる
}

デフォルトでは、pivot オブジェクトには article_idtag_id しか入っていないため、追加したカラムにアクセスすると値が取れず、エラーになってしまいます。

✅ After:withPivotで取得したいカラムを明示する

<?php
// app/Models/Article.php

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

$article = Article::find(1);
foreach ($article->tags as $tag) {
    echo $tag->pivot->sort_order; // 正しく取得できる
}

withPivot() に取得したいカラム名を渡すことで、pivot オブジェクトからそのカラムの値を読み書きできるようになります。

withTimestamps() を追加すると、中間テーブルの created_at / updated_at も自動で管理してくれるので、あわせて覚えておくと便利です。

応用:中間テーブルに専用モデルを持たせる

pivotをそのまま使うと限界が出る場面

中間テーブルにビジネスロジックを持たせたくなったら、pivot の代わりに専用モデルを使う using() メソッドという選択肢があります。

たとえば「タグの付与理由」や「承認フラグ」など、中間テーブル自体に複雑な振る舞いを持たせたい場合です。

<?php
// app/Models/ArticleTag.php

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\Pivot;

class ArticleTag extends Pivot
{
    protected $table = 'article_tag';
}
<?php
// app/Models/Article.php

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

Pivot クラスを継承した専用モデルを作ることで、中間テーブルにもアクセサやリレーションを定義できるようになります。

シンプルな多対多であれば pivot のままで十分ですが、「中間テーブルにも業務ロジックが必要になってきたな」と感じたタイミングで、この選択肢を思い出してもらえればOKです。

まとめ

この記事のポイント

  • 多対多リレーションは、間に中間テーブルを挟むことで両方向の「複数」関係を表現する
  • モデルには belongsToMany を使い、命名規則に沿えばキー名は自動推測される
  • attach / detach / sync でリレーションの追加・削除・同期を行う
  • 中間テーブルの追加カラムは withPivot() を書かないと取得できない
  • 複雑なロジックが必要なら using() で専用のPivotモデルを持たせる選択肢もある

次に読むべき記事

多対多リレーションを試すには、テストデータをどうやって用意するかが次のハードルになります。

→ 次の記事:Seederでテストデータを入れる3つの方法

タイトルとURLをコピーしました