Laravel で多対多リレーションを組むとき、attach()
を 1 行書くだけでレコードは増えます。動きます。
——でも、それが **「壊れない」**と言える保証が欲しくなる時期じゃありませんか?
10 年ほど前、EC サイトの在庫管理で 注文 (orders) と 商品 (products) をつなぐ中間テーブルに 「割引率」「付与ポイント」といったカラムを後付けしたことがあります。
結果、気づかないうちに 同じ行が二重・三重に増殖 し、在庫と売上の数字がズレてしまいました。
原因は、本来必要だった 複合キーと履歴用カラム を設計段階で抜かしていたこと。
たったそれだけの見落としで、運用が超面倒になったって経験があります。
この記事では、そんな 「動くけど不安」 を 「未来の自分が安心できる設計」 に寄せるためのチェックポイントをまとめます。
withPivot()
で追加カラムをちゃんと扱うsoftDeletes()
とtimestamps
で履歴を守るsyncWithoutDetaching()
で二重行を防ぐ
どれも小さな工夫ですが、明日のデバッグ時間を削ってくれるはずです。
肩の力を抜いて、一緒に確認していきましょう。
- 1. Pivot(ピボット)テーブルとは?
- 2. 「attach() だけで十分?」
- 2.1. 🚀 attach / detach / sync ――3 兄弟ざっくり比較
- 2.2. 🔴 見えない 4 つの落とし穴
- 2.3. ✅ この記事でここまで守る
- 3. attach()が怖いのは、withPivot が無いからだ
- 3.1. 🤔 そもそも withPivot() って何をする?
- 3.2. 🗂 ユースケース早見表 — “この列、どこに置く?”
- 3.3. 🛠 最低限の設計テンプレ
- 3.4. 🚨 “withPivot 抜き” で実際に起こったバグ集
- 3.5. ✋ ここまでのまとめ
- 4. 👻Soft-delete したら pivot 行は消える?
- 4.1. なぜ起きる? ── SoftDeletes は“モデル専用”トリック
- 4.2. SoftDeletes は Eloquent モデルにしか効かない
- 4.3. attach / detach が物理操作
- 4.4. withPivot 忘れによる“無言の失敗”
- 4.5. 回避フロー:生きる/眠る/蘇る をすべて宣言する
- 4.6. 実施例 — 付与 → 論理削除 → 復活
- 5. 🛠️さらに深掘り — 中間モデル化で得られる 3 つのご利益
- 5.1. ビルトイン SoftDeletes
- 5.2. 専用スコープ & ファクトリが書ける
- 5.3. Observer で監査ログを付与
- 5.4. 🧪Quick Test Snippet — 眠っている関係が復活しないことを確認
- 6. 📌まとめ & 次の記事予告
Pivot(ピボット)テーブルとは?
user_id
とrole_id
を持つuser_role
という中間テーブルで多対多のリレーションを構築している場合、
Laravelではその中間テーブルのレコード情報は、関連モデルにくっついてpivot
というプロパティとして参照できます。
$user = User::find(1);
foreach ($user->roles as $role) {
echo $role->name;
echo $role->pivot->created_at; // ← これが中間テーブル(user_role)の情報
}
$user->roles
は関連する Role モデルのリスト$role->pivot
はその Role に対する中間テーブル(user_role)の行- 中間テーブル(user_role)に
created_at
やsome_flag
があったら、それもpivot
経由で取れる

あー、リレーションでモデル取ったときに、中間テーブルの情報も一緒に取ってきて、なんかpivotってとこに入れてくれてるんだねぇ〜
「attach() だけで十分?」
Laravel の公式ドキュメントに載っているこの 3 行、書いた瞬間にレコードは増えます。
// ユーザーにロールを付与(典型:role_user)
$user->roles()->attach($roleId, [
// 追加カラムを放り込みがち
]);
ここで一度、似ているようで挙動が違う attach / detach / sync を整理しておきましょう。
🚀 attach / detach / sync ――3 兄弟ざっくり比較
メソッド | すること | よく使う場面 |
attach() | 行を追加。同じペアが既にあっても怒らない | 単発で付与したいとき(=二重行に注意) |
detach() | 行を 削除。ID を省略すると全部消える | ユーザー退会時に全ロール削除など |
sync() | 渡した ID 配列に 完全同期(不要行は削除→追加) | 編集画面でチェックボックス送信 → 差分更新 |
syncWithoutDetaching() | 渡した ID を 追加。既存はそのまま | 二重行防止しつつ「足し算」したいとき |
以降の記事では 追加 = syncWithoutDetaching()、削除 = detach() をデフォルトにして「壊れない」型を作ります。
動く。エラーも出ない。──でも、本番で本当に大丈夫?
🔴 見えない 4 つの落とし穴
落とし穴 | 何が起きる? | 症状の出方 |
---|---|---|
二重行 | 同じ (user_id, role_id) が複数行 | 集計・JOIN が倍増/在庫ズレ |
履歴ロスト | created_at / updated_at が無い | 「いつ付与? 誰が?」が追えない |
Soft‑delete未対応 | 論理削除後に attach → ゴミ行復活 | 退会ユーザーが“復活”する |
N+1 クエリ | foreach ($users as $u) $u->roles | 10ユーザーで11クエリ → 本番だけ遅い |
✅ この記事でここまで守る
- 二重行 →
PRIMARY KEY (user_id, role_id)
+syncWithoutDetaching()
- 履歴 →
withPivot(...)->withTimestamps()
- Soft‑delete → pivot に
deleted_at
+updateExistingPivot()
- N+1 →
with('roles')
で先読み + Telescope で確認
attach()が怖いのは、withPivot が無いからだ
「pivot テーブルにカラム足したいだけなのに、何でこんなに面倒?」 ──最初は誰もがそう思います。
でも、本番で “あれ? いつ付与した? 誰が操作した?” が追えなくなった瞬間、withPivot()
と履歴の大切さが骨身に染みます。
🤔 そもそも withPivot()
って何をする?
Laravel は「withPivot() で明示したカラムしか pivot 経由では返しません」。 たとえ中間テーブルに列が物理的に存在しても、宣言しなければ 「無かったこと」 として扱われ、取得時は null、保存時も fill されません。
だから withPivot() は“付けてもいいオプション”ではなく、“使うなら必須宣言” になるわけです。

さすがに罠すぎるでしょ〜。
💡 勘のいい人用の寸劇
👦:「なんで、withPivotしないとダメなんですか?」
Laravel:「必要なものは言え。言わなきゃ無いことにする。」 A. 設計思想の問題です。
- モデル側に “中間テーブルの追加カラム” を見せる スイッチ
- 付与/更新/取得のたびに、そのカラムを 自動で fill / save してくれる
public function roles()
{
return $this->belongsToMany(Role::class)
->withPivot('granted_by', 'expires_at')
->withTimestamps();
}
🗂 ユースケース早見表 — “この列、どこに置く?”
シーン | 中間テーブル | 追加カラム | 使わないと起きる事故 |
---|---|---|---|
ロール付与 | role_user | granted_by , expires_at | 誰が付与? いつ切れる? が闇に消える |
注文と商品 | order_product | quantity , price , discount | 売上集計がズレる/返品処理が混乱 |
受講コース | course_user | progress , completed_at | 進捗 0% なのに完了メールが飛ぶ |
追加カラム = ビジネスルール。withPivot() を忘れると「取れない・書けない・テストできない」の三重苦が待ってる。
🛠 最低限の設計テンプレ
- 複合 PRIMARY KEY:
PRIMARY (user_id, role_id)
- 履歴カラム:
created_at
,updated_at
(+deleted_at
) - モデル定義:
public function roles()
{
return $this->belongsToMany(Role::class)
->withPivot(['granted_by', 'expires_at'])
->withTimestamps();
}
書き込みは syncWithoutDetaching
💡 なぜ attach() ではなく syncWithoutDetaching() なのか?
attach()
は 同じ(user_id, role_id)
が存在しても新行を INSERT するため、 追加カラムを持つ pivot では 同一ペアの “二重行” を量産しがち。syncWithoutDetaching()
は 「既にあれば無視、無ければ追加」 の安全な足し算。 つまり 主キーで守りつつ意味のあるカラムだけ更新 できる。- これにより 二重行を物理&論理の両面でブロック しながら、
granted_by
,expires_at
などのビジネス情報を壊さず維持できる。**
$user->roles()->syncWithoutDetaching([
$roleId => [
'granted_by' => $adminId,
'expires_at' => now()->addMonth(),
],
]);
🚨 “withPivot 抜き” で実際に起こったバグ集
症状 | 原因 | 後始末コスト |
---|---|---|
退会ユーザーがログイン後に旧ロール復活 | deleted_at 不在で attach() 二重行 | DB 手作業削除 + ログ監査 3h |
売上月次レポートが 102% | price を pivot で保持せず products.price 参照 | 差分突合・再計算 1日 |
アップデートで granted_by 欄が null | withPivot() 忘れ → fill されず | マイグレーション & データ移行 4h |
✋ ここまでのまとめ
- 追加カラム = ビジネスルール → 置き場所は pivot 一択
withPivot()
+withTimestamps()
を モデルに書くのは初日に済ませる- 複合 PK &
syncWithoutDetaching()
で 二重行を物理的にブロック
👻Soft-delete したら pivot 行は消える?
退会させたはずのユーザーが、数日後 “幽霊ロール” を身に着けて復活──そんな都市伝説、あなたの DB でも囁かれていませんか?
たねまる:ぼくのチームでも出たよ〜。朝イチでログインしてきて肝が冷えたもん…。
なぜ起きる? ── SoftDeletes は“モデル専用”トリック
SoftDeletes は Eloquent モデルにしか効かない
pivot はモデル扱いされないため、$user->delete()
しても
role_user
には 自動では 何も起きない。
// User モデルだけに SoftDeletes トレイト
class User extends Model {
use SoftDeletes;
}
$user = User::find(1);
$user->delete(); // users.deleted_at は埋まるが role_user は変化なし
attach / detach が物理操作
attach()
は「INSERT 一択」、detach()
は「DELETE 一択」。
deleted_at
とは連動しないので、論理削除済み行を踏み越えて複製してしまう。
// 退会フロー — 行を物理削除
$user->roles()->detach(); // DELETE FROM role_user
// 再入会フロー — 新規 INSERT
$user->roles()->attach($roleId); // INSERT INTO role_user
// → soft‑deleted 行を無視して重複行が生まれる
withPivot 忘れによる“無言の失敗”
>updateExistingPivot($id, ['deleted_at' => null])
で復活させてもwithPivot('deleted_at')
を宣言していないと Laravel は
「知らんカラム」として黙殺──ログもエラーも残らない。💀
// User モデル側 — deleted_at を宣言し忘れ
public function roles()
{
return $this->belongsToMany(Role::class); // ← withPivot('deleted_at') を書いていない
}
// 復活させた“つもり”…
$user->roles()->updateExistingPivot($roleId, ['deleted_at' => null]);
// → pivot 行は更新されず、処理は静かにスルーされる
ここまでで「なぜ起きるか」を説明しました。
では、「どう防ぐか?」を整理しておきましょう。
論理削除を安全に運用するには、「構造」「宣言」「操作」の3レイヤーで意思を持って実装する必要があります。
以下にその回避フローを一覧にまとめました。
回避フロー:生きる/眠る/蘇る をすべて宣言する
ステップ | 処理 | 例 |
---|---|---|
① 構造 | pivot に deleted_at 列を作る | ->timestamp('deleted_at')->nullable() |
② 宣言 | モデルに withPivot('deleted_at') | User::roles() 側で |
③ 眠らせる | updateExistingPivot($id, ['deleted_at' => now()]) | 退会時 |
④ 蘇らせる | updateExistingPivot($id, ['deleted_at' => null]) | 再入会時 |
⑤ 守る | 中間モデル化+SoftDeletes トレイト | RoleUser extends Pivot |
実施例 — 付与 → 論理削除 → 復活
$admin = Auth::user(); // 操作ユーザー(権限付与者)
$user = User::first(); // 対象ユーザー
$role = Role::where('slug', 'manager')->first();
// 1. 付与 — syncWithoutDetaching で二重行を防ぎつつ “足し算”
$user->roles()->syncWithoutDetaching([
$role->id => [
'granted_by' => $admin->id,
'expires_at' => now()->addMonth(),
],
]);
// 2. 眠らせる — 論理削除(退会)
$user->roles()->updateExistingPivot($role->id, [
'deleted_at' => now(),
]);
// 3. 復活させる — deleted_at を null に戻す(再入会)
$user->roles()->updateExistingPivot($role->id, [
'deleted_at' => null,
]);
// 4. 確認 — pivot 行の deleted_at が null (=生きている)
$activeRole = $user->load('roles')->roles->first();
logger('復活後 deleted_at:', [$activeRole->pivot->deleted_at]); // null
ポイント
- syncWithoutDetaching: 既存ロールを保持しつつ追加。二重行を作らない。
- updateExistingPivot: 結び目(pivot 行)のみを更新する安全ルート。
- SoftDeletes on Pivot:
RoleUser::onlyTrashed()->count()
で “眠っている関係” も集計できる。
🛠️さらに深掘り — 中間モデル化で得られる 3 つのご利益
ビルトイン SoftDeletes
RoleUser extends Pivot
に SoftDeletes
を付けておけば、onlyTrashed()
/ withTrashed()
がそのまま使える。
// app/Models/RoleUser.php
class RoleUser extends Pivot
{
use SoftDeletes;
protected $table = 'role_user';
protected $dates = ['deleted_at'];
}
専用スコープ & ファクトリが書ける
scopeActive()
で whereNull('deleted_at')
を共通化したり、Factory でテスト用データを量産できる。
// RoleUser.php — スコープ
public function scopeActive($query)
{
return $query->whereNull('deleted_at');
}
// database/factories/RoleUserFactory.php (Laravel 9+)
class RoleUserFactory extends Factory
{
protected $model = RoleUser::class;
public function definition()
{
return [
'user_id' => User::factory(),
'role_id' => Role::factory(),
'deleted_at' => null, // Active 状態
];
}
}
Observer で監査ログを付与
RoleUserObserver
を登録すれば、付与・削除・復活イベントごとに Slack 通知や履歴テーブルへの書き込みも楽勝。
// app/Observers/RoleUserObserver.php
class RoleUserObserver
{
public function updated(RoleUser $pivot)
{
if ($pivot->isDirty('deleted_at')) {
AuditLog::create([
'role_user_id' => $pivot->id,
'changed_by' => Auth::id(),
'diff' => $pivot->getChanges(),
]);
// Slack::notify(...); などもここで
}
}
}
// AppServiceProvider などで登録
RoleUser::observe(RoleUserObserver::class);
🧪Quick Test Snippet — 眠っている関係が復活しないことを確認
前章で組み上げた SoftDeletes 対応の中間モデル + スコープ + Observer が、本当に 退会→再入会 フローを守ってくれるか――コードで事実確認 してみましょう。
特に「論理削除された関係が意図せず複製されない」かどうかは、再入会処理の信頼性に直結します。
public function test_user_cannot_receive_duplicate_role_after_soft_delete()
{
$user = User::factory()->create();
$role = Role::factory()->create();
// 初回付与
$user->roles()->attach($role->id);
// 論理削除
$user->roles()->updateExistingPivot($role->id, ['deleted_at' => now()]);
// 再付与(attach ではなく updateExistingPivot を経由)
$user->roles()->updateExistingPivot($role->id, ['deleted_at' => null]);
// 二重行なしをアサート
$this->assertDatabaseCount('role_user', 1);
}
📌まとめ & 次の記事予告
- SoftDeletes はモデル限定 — pivot 行は放置される。
- 物理派手 attach/detach に注意 — 二重行とゴミ行を量産しがち。
- withPivot の宣言漏れ=無言の失敗 — ログも出ずに泣くのは未来の自分。
- 中間モデル化+SoftDeletes が最も安全で保守しやすい。
コメントを残す