対象読者: “便利だから” の勢いで sync()
/ syncWithoutDetaching()
を本番投入しつつある Laravel 開発者
この記事で持ち帰れるもの
- “消える・飛ぶ・遅くなる”——sync 系が抱える 5 つの地雷の全貌
- 現場で即コピペできる 3つの回避パターン
同期、それは地獄からの使者💀
便利さの代償は、無慈悲なデータ破壊。
チェックボックスの送り漏れ 1 つでロールが蒸発し、深夜のバッチで DB が悲鳴を上げる──そんな悪夢が、気付いた頃にはログにも残らず過ぎ去っていることさえあります。
この記事は 「attach の事故は避けられた。だが次は sync にやられた…」 という沼を 30 分で抜けるための集中講座です。
まずは結論:同期は凶器にも盾にもなる。
リスクを言語化し、回避策をパターン化すれば事故らないsyncは確実に手の内に落ちます。
- 0.1.1. この記事で持ち帰れるもの
- 1. 同期、それは地獄からの使者💀
- 1.1. 🚨 sync 系メソッドが孕む 5 大リスク
- 1.2. #1望まぬ detach
- 1.3. #2 追加カラム初期化 — pivot のメタ情報が吹き飛ぶ
- 1.4. #3 履歴ロスト — created_at が断絶する
- 1.5. #4 ソフトデリートすり抜け — 幽霊レコードが復活
- 1.6. #5 パフォーマンス爆発 — 大量 sync を直列で回す
- 1.7. 💊 リスクを無力化する 4 原則
- 2. 実践セクション — ケース別ベストプラクティス
- 2.1. 管理画面のチェックボックス同期
- 2.2. API 経由でのロール追加(足し算オンリー)
- 2.3. 定期バッチ:1,000+ 行を安全同期
- 3. まとめ — 同期は凶器にも盾にもなる
🚨 sync 系メソッドが孕む 5 大リスク
# | リスク | 何が起きる? | 典型症状 |
---|---|---|---|
1 | 望まぬ detach | ID 配列 以外 を一括 DELETE | チェックボックス抜け → ロール消滅 |
2 | 追加カラム初期化 | 存在しない行を再 INSERT で pivot が初期値に | granted_by /discount が 0/null |
3 | 履歴ロスト | created_at が分断され監査不全 | ログが日付ジャンプ |
4 | ソフトデリートすり抜け | deleted_at 行も “無い” 判定 → attach で幽霊復活 | 退会ユーザが蘇る |
5 | パフォーマンス爆発 | detach→attach の 2N クエリ+大量 IDs | バッチだけタイムアウト |
#1望まぬ detach
// 編集フォームで roles[] が空配列として飛んだ場合
$user->roles()->sync($request->input('roles', []));
// → 既存ロールを丸ごと detach(ロール剥奪事故)
✅ 対策 – 空配列を門前払い & トランザクション
#2 追加カラム初期化 — pivot のメタ情報が吹き飛ぶ
// もともと pivot に granted_by が入っているとする
$user->roles()->sync([
// ID しか渡さないと既存行は一旦 DELETE → INSERT
5, 7, 9
]);
// 結果:role_user
// +-----------+---------+-------------+------------------+
// | user_id | role_id | granted_by | created_at |
// +-----------+---------+-------------+------------------+
// | 42 | 5 | NULL ←💥 | 2025-04-24 19:12 |
// | 42 | 7 | NULL ←💥 | 2025-04-24 19:12 |
// | 42 | 9 | NULL ←💥 | 2025-04-24 19:12 |
✅ 対策 –
sync([... => ['granted_by'=>…]])
かsyncWithoutDetaching()
を使い、pivot カラムを必ず明示。
#3 履歴ロスト — created_at
が断絶する
// 1 年前に付与されたロールを “そのまま維持したい” つもりだった
$user->roles()->sync([3, 4]);
// ※ pivot に created_at があると、ここで DELETE → INSERT
// 監査ログを遡ると… 1 年前の付与日がごっそり消失
✅ 対策 – 変更履歴を保持したいなら
- 履歴テーブル を分離する
- 連番 PK + deleted_at 方式 &
restore()
で履歴をつなげる。
#4 ソフトデリートすり抜け — 幽霊レコードが復活
// pivot テーブルに deleted_at を採用しているケース
$user->roles()->wherePivotNotNull('deleted_at')->get(); // ← 退会ユーザ確認 OK
// ↓ sync() が “deleted_at 行も無いもの扱い” で attach し直す
$user->roles()->sync([1, 2]);
// 結果:deleted_at が NULL になり “退会済み” が生き返る
✅ 対策 –
- pivot 側も
SoftDeletes
を使いwithTrashed()
で検知して復元するか、- “論理削除=別テーブルに退避” パターンへ転換。
#5 パフォーマンス爆発 — 大量 sync を直列で回す
// ✖️ 典型的 “全ユーザ × 全ロール” 鬼回しバッチ
foreach (User::all() as $user) {
// ここで毎回 :DELETE + INSERT × 役割数
$user->roles()->sync($targetRoleIds); // ← 数万行なら CPU & I/O が炎上
}
発火症状 : DB CPU100%、バッチだけタイムアウト、CloudWatch が真っ赤
改善ワンライナー : syncWithoutDetaching()
に切替えて削除ゼロ化
本命 : User::cursor()
/chunk()
× syncWithoutDetaching()
× インデックス最適化
// ✔️ 爆発を防ぐ一例
User::chunk(500, function ($users) use ($targetRoleIds) {
foreach ($users as $user) {
$user->roles()->syncWithoutDetaching($targetRoleIds);
}
});
💊 リスクを無力化する 4 原則
- 足し算だけ なら
syncWithoutDetaching()
— 削ぎ落としゼロ - 完全同期が要る UI なら 空配列拒否+トランザクション
- 複合 PK + withPivot で 消せないメタ情報 を死守
- Telescope / Query Log でクエリ数を可視化し “DELETE 連打” を即把握
実践セクション — ケース別ベストプラクティス
管理画面のチェックボックス同期
抜け=事故になる UI は 必ず空配列を弾く。
$ids = $request->validate([
'roles' => 'required|array|min:1', // 空チェック禁止
])['roles'];
DB::transaction(fn () => $user->roles()->sync($ids));
- hidden+JavaScript で “元の ID” をフォームに残し、送信漏れを物理的に防ぐと安心。
API 経由でのロール追加(足し算オンリー)
ビジネス情報を壊さず 追加だけ したい場合。
$user->roles()->syncWithoutDetaching([
$roleId => ['granted_by' => $operatorId],
]);
- 複合 PK (
PRIMARY (user_id, role_id)
) が二重行を自動ブロック。
定期バッチ:1,000+ 行を安全同期
大規模 sync は chunk/cursor と syncWithoutDetaching() で DB を守る。
User::chunk(500, function ($users) use ($targetRoles) {
foreach ($users as $user) {
$user->roles()->syncWithoutDetaching($targetRoles);
}
});
- detach→attach を避けるだけで クエリ数が 1/2 以下、タイムアウト回避にも直結。
まとめ — 同期は凶器にも盾にもなる
sync()
は削ぎ落とし前提、syncWithoutDetaching()
は足し算専用。用途を決め打ちして握り替える。- 空配列バリデーション+トランザクション で “消える” リスクを根絶。
- 複合 PK+pivot カラム設計 が履歴・メタ情報を守る最後の盾。
- Telescope/Query Log で DELETE & INSERT 連打 を可視化し“事故の芽”を見逃さない。
🚀 次の一歩
Pivot 設計とattach
の二重行問題を深掘りした前編
「belongsToMany × withPivot ── 動く だけ じゃ終わらせない中間テーブル運用ガイド」 も併せてどうぞ!
コメントを残す