先に要点
中間テーブルは、データベース設計を学び始めるとかなり早い段階で出てくる考え方です。 でも最初は、なぜテーブルが1枚増えるのか、外部キーをどちらかに置くだけではだめなのか、が少し分かりにくいです。
特に ORM を使っていると、コード上では belongsToMany や ManyToManyField のように見えて、裏でどんな表が必要なのかを意識しないまま進むこともあります。
ただ、一覧が重い、重複行が増える、関係に追加情報を持たせたい、といった場面では、結局テーブル設計の理解が効きます。
今回は、中間テーブルを多対多を表すための表として整理しつつ、Laravel の pivot table との違い、追加カラムを持たせる場面、そして「橋渡しか独立モデルか」の判断と重複行による集計事故まで、実コードを交えてまとめます。
中間テーブルとは
中間テーブルとは、2つの表のあいだに入って、どの行とどの行が結びついているかを記録する表です。 代表例は、ユーザーと権限、記事とタグ、商品と注文の関係です。
たとえば users と roles なら、こんなイメージになります。
- 1人のユーザーが複数の権限を持てる
- 1つの権限が複数のユーザーに割り当てられる
このとき users に role_id を1本置くと、1人のユーザーに1つの権限しか持てません。
逆に roles に user_id を置くと、1つの権限が1人のユーザーにしか属せない形になります。
つまり、両側が複数を持てる多対多では、どちらか片方に外部キーを置くだけでは表現しきれません。
そこで role_user のような中間テーブルを作り、user_id と role_id の組み合わせを行として持たせます。
なぜ多対多では中間テーブルが必要になるのか
リレーショナルデータベースでは、1つのセルに 1, 3, 8 のような複数IDを雑に詰め込む設計は扱いにくくなりがちです。
検索、集計、更新、重複防止、整合性チェックが全部つらくなります。
たとえば users.roles = "admin,editor" のように文字列で持ってしまうと、次のような問題が出ます。
adminを持つユーザーだけ検索しにくい- 権限名の変更で一括置換が必要になる
- 同じ権限が重複して入っても気づきにくい
- 外部キー制約を貼れない
中間テーブルを使うと、user_id = 3 と role_id = 2 のように1行ずつ持てるので、SQL の JOIN や集計とも相性がよく、整合性も取りやすくなります。
具体例: users と roles の関係
テーブルを単純化すると、次の3枚になります。
| テーブル | 主な列 | 役割 |
|---|---|---|
| users | id, name |
ユーザー本体を持つ |
| roles | id, name |
権限の定義を持つ |
| role_user | user_id, role_id |
どのユーザーがどの権限を持つかを結ぶ |
role_user に次のような行が入っているイメージです。
| user_id | role_id |
|---|---|
| 1 | 2 |
| 1 | 3 |
| 5 | 2 |
これなら、ユーザー1は role 2 と 3 を持つ、role 2 はユーザー1と5に付いている、が素直に表現できます。
中間テーブルを作るときの最小の流れは、おおむね次の通りです。
Laravel の pivot table とは何が違うのか
実務では中間テーブルと pivot table がほぼ同じ意味で使われることがあります。
ただし、ニュアンスとしては少し違います。
中間テーブル
データベース設計の一般的な言い方。フレームワークに依存しない用語で、設計の議論で通じやすい。
Laravel の公式ドキュメントでも、many-to-many の関係に対して intermediate table や pivot という表現が出てきます。
つまり Laravel 文脈では pivot table が定着していますが、DB設計全般の話としては中間テーブルと言っておく方が通じやすいです。
ここで初心者がつまずきやすいのが命名です。Laravel の belongsToMany はデフォルトで、関連する2つのモデル名をアルファベット順にスネークケースでつないだ表名を期待します。User と Role なら user_role ではなく role_user です(r が u より先)。自分で user_role という表を作ってしまうと、第2引数で明示しない限り Laravel は role_user を探しに行き、テーブルが見つからずエラーになります。
// 表名を規約に任せる場合は role_user を用意する
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
// 自分の命名(user_role)を使うなら第2引数で明示する
return $this->belongsToMany(Role::class, 'user_role');
また、Django では many-to-many を ManyToManyField で扱い、追加情報を持たせたいときは through モデルの考え方が出てきます。
Prisma でも implicit と explicit の many-to-many があり、implicit では _CategoryToPost のように先頭アンダースコア付きの隠れ表(列は A と B)を自動生成します。追加メタデータが必要なら relation table を明示モデルとして扱う explicit 形に切り替えます。
つまりフレームワークごとに呼び方は少し違っても、根っこにあるのは多対多の関係を1行ずつ持つ表が必要、という同じ考え方です。
中間テーブルに追加カラムを持たせる場面
中間テーブルはIDを2本並べるだけの表で終わるとは限りません。 実務では、関係そのものに意味があるので追加カラムを持たせることがよくあります。
たとえばこんな列です。
assigned_atいつ割り当てたかassigned_by誰が割り当てたかquantity何個ひも付いているかstatus有効、保留、無効などsort_order表示順
たとえば orders と products の間なら、中間テーブルに quantity や unit_price を持たせたくなります。
ユーザーとチームの関係なら、owner member のような役割や参加日時を置きたくなることがあります。
Laravel では、規約どおりの列だけだとアクセスできないので、withPivot() で追加列を読み込み、タイムスタンプが要るなら withTimestamps() を付けます。
return $this->belongsToMany(Role::class)
->withPivot('assigned_by', 'status')
->withTimestamps();
// 取り出し
foreach ($user->roles as $role) {
echo $role->pivot->assigned_by;
}
この段階になると、単なる橋渡しというより、関係自体が1つのデータとして育ってきます。
「単なる橋渡し」か「独立したモデル」かを実コードで見極める
監査でも指摘の多い分岐がここです。中間テーブルを薄い隠れ表のままにするか、独立したモデルに昇格させるか。判断は「その関係に固有の名前と振る舞いがあるか」で決めます。
分かりやすいのが受講登録の例です。最初は学生と講座の単純な多対多に見えます。
// 最初の設計: ただの橋渡し
// students -- course_student -- courses
public function courses(): BelongsToMany
{
return $this->belongsToMany(Course::class);
}
ところがリリース後、「成績(grade)」「登録日」「キャンセル可否」「再履修フラグ」といった要望が積み上がります。withPivot() の列が4本5本と増え、course_student の1行が「登録」という業務上の名詞になってきます。この瞬間が橋渡しから独立モデルへの移行点です。
橋渡しのままで良い
列が student_id と course_id だけ、せいぜいタイムスタンプ程度。関係に固有の名前がない。タグ付けや権限割り当てなど。belongsToMany + withPivot() で十分。
独立モデルに昇格
関係そのものに「登録」「予約」「契約」のような名前が付き、状態遷移や金額、別テーブルとの関連が増える。Enrollment モデルとして扱い hasMany でつなぐ。
Laravel での移行は、表名を業務語に変えたうえで Pivot を介さず通常モデルにします。
// 昇格後: course_student を enrollments にして独立モデル化
// Student
public function enrollments(): HasMany
{
return $this->hasMany(Enrollment::class);
}
// Enrollment(独立モデル。grade や status を普通のカラムとして持つ)
class Enrollment extends Model
{
protected $fillable = ['student_id', 'course_id', 'grade', 'status'];
}
Prisma でも同じ判断になり、メタデータが要るなら implicit をやめ、Enrollment を explicit な join モデルとして書きます。Django なら ManyToManyField(through='Enrollment') です。
迷ったときの目安は、「その1行を SELECT して画面に出したくなるか」。出したくなるなら、それはもう独立した概念です。逆に、UI に出るのは常に「学生から見た講座一覧」だけなら橋渡しのままで構いません。
よくある設計ミスと、重複行で集計が壊れる事故
1. 複合主キーを付け忘れ、重複行で集計が水増しされる
これは実害が大きいわりに気づきにくい、典型的な事故です。
現象 権限を持つユーザー数を数える集計が、実際より多い値を返す。ダッシュボードの「editor 権限保有者: 1,480人」が、人事台帳の実数(約900人)と合わない。
原因
role_user に (user_id, role_id) の複合主キーも UNIQUE 制約も付けていなかった。さらに付与処理で sync() ではなく attach() を使っており、再付与ボタンの二度押しやリトライで user_id=42, role_id=3 がそのまま2行、3行と増えていた。attach() は既存行を消さずに追加するため、一意制約がないと重複が物理的に防げません。一覧表示は DISTINCT 相当で人間が見過ごせても、COUNT(*) や SUM(quantity) は重複行をそのまま足し込むので、集計だけが静かに水増しされます。
確認手順 まず重複が存在するかを直接数えます。
SELECT user_id, role_id, COUNT(*) AS cnt
FROM role_user
GROUP BY user_id, role_id
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
-- 例: 42 | 3 | 3 (1行であるべき組が3行ある)
集計クエリ側でも、素朴な COUNT と重複排除版の差を見ると影響量が分かります。
SELECT
COUNT(*) AS naive_count, -- 重複込み 1480
COUNT(DISTINCT user_id) AS real_count -- 実数 900
FROM role_user WHERE role_id = 3;
回避 設計時に一意制約を入れておくのが本筋です。Laravel のマイグレーションなら次のように複合主キー(または UNIQUE)を張ります。
Schema::create('role_user', function (Blueprint $table) {
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->primary(['user_id', 'role_id']); // ここが防波堤
});
すでに重複が入ってしまった本番では、先に重複行を1行に潰してから制約を追加します(削除前に必ずバックアップを取ること)。付与ロジックも、全置換で良いなら $user->roles()->sync([1,2,3]) に寄せると、差分だけ付け外しされ重複が生まれません。追加のみで良い場面でも syncWithoutDetaching() を使えば既存はそのまま重複だけ防げます。
2. 片側1対多で十分なのに中間テーブルを作る
1人の社員は1つの部署にだけ所属する、のように、実は多対多ではないなら、中間テーブルは不要です。 設計を複雑にする前に、関係が本当に多対多かを確かめた方がよいです。
3. 追加カラムが増えたのに「ただの橋渡し」のまま扱う
status start_at end_at note まで増えてくると、その表はかなり意味を持ち始めます。
前章のとおり、隠れテーブルではなく独立したモデルとして扱った方が読みやすくなります。
4. 命名規則だけで分かった気になる
フレームワークが自動で扱ってくれても、裏では JOIN しているだけです。
ORM が便利でも、遅いクエリや重複結果に向き合うときは、テーブル構造と SQL の見え方を理解している方が強いです。
中間テーブルに関するよくある質問
Q. 中間テーブルの命名規則は?
A. users と roles なら、Laravel のデフォルトはモデル名のアルファベット順 + スネークケースで role_user です(user_role ではない点に注意)。独自命名を使うなら belongsToMany(Role::class, 'user_role') のように第2引数で明示します。最重要なのはチーム内で1つに統一することです。
Q. 主キーはどう設定する?
A. (user_id, role_id) の複合主キーが定番で、同じ組み合わせの重複を物理的に防げます。Laravel では自動増分 id を主キーにしつつ (user_id, role_id) に UNIQUE を別途張る選択肢もあります。どちらにせよ一意制約を必ず入れることが、重複集計事故を防ぐ最大のポイントです。
Q. 中間テーブルに追加カラムを持たせて良い?
A. はい。created_at / updated_at などのタイムスタンプや、assigned_by(誰が付与したか)などの関連情報を追加できます。Laravel では withPivot() で列を読み込み、タイムスタンプは withTimestamps() で自動管理できます。
Q. 中間テーブル自体に意味があるなら?
A. 独立したモデルにします。orders テーブルは一見 users と products の中間に見えますが、注文自体が独立した概念です。判断軸は「その1行を画面に出したくなるか」「業務上の名前(登録・予約・契約など)が付くか」。付くなら Enrollment のような独立モデルに昇格させます。
Q. N+1 問題はどう避ける?
A. Laravel なら with("roles") で eager loading し、pivot 列で絞るなら wherePivot('status', 'active') を使います。一覧で関連を1件ずつ取りに行く N+1 を、最小限のクエリでまとめて取る設計に寄せるのが基本です。
Q. 削除時の挙動は?
A. 親レコード(user または role)が削除されたとき、関連する中間テーブル行を ON DELETE CASCADE で道連れ削除するか、SET NULL にするかを決めます。孤立行を残さないために、外部キー制約 + ON DELETE で自動化するのが一般的です。Laravel なら cascadeOnDelete() で書けます。
Q. attach と sync の違いは?
A. attach() は既存行を消さずに追加するため、一意制約がないと重複行が増える原因になります。sync([1,2,3]) は渡した集合に合わせて差分を付け外しし、含まれない行は detach します。既存を消さずに重複だけ防ぎたいなら syncWithoutDetaching() が使えます。
Q. 多対多の代替パターンは?
A. JSON 列に role 一覧を埋め込む、カンマ区切り文字列で持つ、といった代替もありますが、JOIN が書けない、整合性が崩れる、型安全性が低いといった理由で基本的に推奨されません。中間テーブルが王道です。
まとめ
中間テーブルは、多対多を表すために2つの表のあいだへ入る表です。 片方に外部キーを置くだけでは表現しきれない関係を、組み合わせの行としてきれいに持てるようにします。
覚え方としては、次の3つを押さえるとかなり整理しやすいです。
- 片側にも反対側にも複数ぶら下がるなら、多対多を疑う
- 多対多なら、中間テーブルで関係を1行ずつ持ち、
(外部キー, 外部キー)に一意制約を必ず張る - 関係に固有の名前や状態が増えたら、独立モデルへの昇格を考える
Laravel の pivot table、Django の through、Prisma の explicit relation table など、道具ごとの言い方は違っても、考え方の芯はほぼ同じです。
フレームワークの便利機能だけで覚えるより、なぜ1枚増えるのかと、一意制約を外すと集計がどう壊れるかを先に理解しておくと、後でかなり効きます。
データベース設計の全体像をもう少し広げたいなら ORMとは?何が便利?SQLを知らなくていいわけではない理由を初心者向けに解説 や、アプリ側の代表的なフレームワークは Laravelとは?何ができる?向いている開発を初心者向けに解説 もつながります。
参考情報
- Laravel Docs: Eloquent Relationships
- Django Docs: Many-to-many relationships
- Prisma Docs: Many-to-many relations