メシのタネ

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


Laravel Eloquent sync 系メソッド完全攻略 — 安全な同期のための 5 大リスクと回避策


  1. Laravel
  2. Laravel Eloquent sync 系メソッド完全攻略 — 安全な同期のための 5 大リスクと回避策

対象読者: “便利だから” の勢いで sync() / syncWithoutDetaching() を本番投入しつつある Laravel 開発者

この記事で持ち帰れるもの

  • “消える・飛ぶ・遅くなる”——sync 系が抱える 5 つの地雷の全貌
  • 現場で即コピペできる 3つの回避パターン

同期、それは地獄からの使者💀

便利さの代償は、無慈悲なデータ破壊。
チェックボックスの送り漏れ 1 つでロールが蒸発し、深夜のバッチで DB が悲鳴を上げる──そんな悪夢が、気付いた頃にはログにも残らず過ぎ去っていることさえあります。

この記事は 「attach の事故は避けられた。だが次は sync にやられた…」 という沼を 30 分で抜けるための集中講座です。
まずは結論:同期は凶器にも盾にもなる
リスクを言語化し、回避策をパターン化すれば事故らないsyncは確実に手の内に落ちます。

🚨 sync 系メソッドが孕む 5 大リスク

#リスク何が起きる?典型症状
1望まぬ detachID 配列 以外 を一括 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 原則

  1. 足し算だけ なら syncWithoutDetaching() — 削ぎ落としゼロ
  2. 完全同期が要る UI なら 空配列拒否トランザクション
  3. 複合 PK + withPivot で 消せないメタ情報 を死守
  4. 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/cursorsyncWithoutDetaching() で DB を守る。

User::chunk(500, function ($users) use ($targetRoles) {
    foreach ($users as $user) {
        $user->roles()->syncWithoutDetaching($targetRoles);
    }
});
  • detach→attach を避けるだけで クエリ数が 1/2 以下、タイムアウト回避にも直結。

まとめ — 同期は凶器にも盾にもなる

  1. sync() は削ぎ落とし前提、syncWithoutDetaching() は足し算専用。用途を決め打ちして握り替える。
  2. 空配列バリデーション+トランザクション で “消える” リスクを根絶。
  3. 複合 PK+pivot カラム設計 が履歴・メタ情報を守る最後の盾。
  4. Telescope/Query Log で DELETE & INSERT 連打 を可視化し“事故の芽”を見逃さない。

🚀 次の一歩
Pivot 設計と attach の二重行問題を深掘りした前編
「belongsToMany × withPivot ── 動く だけ じゃ終わらせない中間テーブル運用ガイド」 も併せてどうぞ!


コメントを残す

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

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

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

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

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

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

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

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