メシのタネ

めしのたねになるIT情報配信サイト


belongsToMany 実戦ガイド ── “withPivot”で追加カラムを守る 中間テーブル設計チェックリスト


  1. Laravel
  2. belongsToMany 実戦ガイド ── “withPivot”で追加カラムを守る 中間テーブル設計チェックリスト

Laravel で多対多リレーションを組むとき、attach() を 1 行書くだけでレコードは増えます。動きます。

——でも、それが **「壊れない」**と言える保証が欲しくなる時期じゃありませんか?

10 年ほど前、EC サイトの在庫管理で 注文 (orders)商品 (products) をつなぐ中間テーブルに 「割引率」「付与ポイント」といったカラムを後付けしたことがあります。

結果、気づかないうちに 同じ行が二重・三重に増殖 し、在庫と売上の数字がズレてしまいました。

原因は、本来必要だった 複合キーと履歴用カラム を設計段階で抜かしていたこと。

たったそれだけの見落としで、運用が超面倒になったって経験があります。

この記事では、そんな 「動くけど不安」「未来の自分が安心できる設計」 に寄せるためのチェックポイントをまとめます。

  • withPivot() で追加カラムをちゃんと扱う
  • softDeletes()timestamps で履歴を守る
  • syncWithoutDetaching() で二重行を防ぐ

どれも小さな工夫ですが、明日のデバッグ時間を削ってくれるはずです。

肩の力を抜いて、一緒に確認していきましょう。

Pivot(ピボット)テーブルとは?

user_idrole_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_atsome_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->roles10ユーザーで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_usergranted_by, expires_at誰が付与? いつ切れる? が闇に消える
注文と商品order_productquantity, price, discount売上集計がズレる/返品処理が混乱
受講コースcourse_userprogress, completed_at進捗 0% なのに完了メールが飛ぶ

追加カラム = ビジネスルール。withPivot() を忘れると「取れない・書けない・テストできない」の三重苦が待ってる。

🛠 最低限の設計テンプレ

  1. 複合 PRIMARY KEY: PRIMARY (user_id, role_id)
  2. 履歴カラム: created_at, updated_at (+ deleted_at)
  3. モデル定義:
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 欄が nullwithPivot() 忘れ → 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 PivotSoftDeletes を付けておけば、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 が最も安全で保守しやすい。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

This site uses Akismet to reduce spam. Learn how your comment data is processed.

若い頃、「仕事中にハマったこと」や「誰かに共有したい技術的な気づき」をアウトプットしたくてブログを始めましたが、勢い任せでよく分からない記事を大量生産し、あえなく飽きて終了。

改めて今、キャリア15年分の経験や知識が、これからITエンジニアを目指す方や、同じような課題で悩んでいる現役エンジニアの「メシのタネ」になるような記事を残したいと思っています。
※過去の記事は見ると精神が崩壊するため、そっとしておいてください。

🛠 経歴という名の珍道中:
文系Fラン → 広告営業 → Web営業 → 通信営業 → Web進行 → 出版 → Web媒体運用 → ソフトウェアハウス → SES → フリーランス

専門教育も受けず、転職歴も多数。履歴書はまるで時系列の事故記録のようですが、試行錯誤を重ね、なんとかエンジニアとして食べています。

このブログでは、そんな「履歴書クラッシャー型エンジニア」が送る、
名古屋一敷居の低い、実務に役立つ技術ブログを目指します。

Laravel
belongsToMany 実戦ガイド ── “withPivot”で追加カラムを守る 中間テーブル設計チェックリストNew!!
PHP
魔王と行く! / Interface / Polymorphism / Ontology 深淵ガイドNew!!
Laravel
Laravel 12、「コード 1 行も書き換えず未来へ」──静かな革命の手順書New!!
Laravel
LaravelのMiddlewareって意味あるの?仕組み・使いどころ・やらかしまで整理してみたNew!!
Laravel
ServiceProviderって何してるの?DIの背後で動いてるやつの正体
Laravel
LaravelのサービスコンテナとDI、「書いてるだけで動く」コードの正体