Laravel

Laravel 1対多・多対多の更新はsync()で決まり!使い方と注意点

こんにちは!Laravelでの開発を楽しんでいますか?

LaravelのEloquent ORMはリレーションを非常に簡単に扱えますが、「1対多」や「多対多」の関連データをフォームから受け取って登録・更新する処理は、意外と面倒に感じることがあります。特に、チェックボックスで選択されたカテゴリIDを中間テーブルに保存するようなケースです。

「既存の関連を全部消して、新しいのを登録し直す?」「いや、差分だけ計算して追加・削除?」

そんな悩みを一発で解決してくれるのが、今回ご紹介する sync() メソッドです。この記事では、SEOキーワード「Laravel 1対多 更新」で検索してたどり着いたあなたにも、sync() の圧倒的な利便性をお伝えします。

あれ? 1対多? 多対多?

「Laravel 1対多 更新」で検索されたかもしれませんが、sync() メソッドが本領を発揮するのは、実は「多対多 (belongsToMany)」リレーションシップです。

例えば「1つの商品 (Product) が複数のカテゴリ (Category) に属し、1つのカテゴリにも複数の商品が属する」といった関係です。この関係は「中間テーブル」を使って実現されます。

もしあなたのケースが「1対多(例: 1ユーザーが複数の投稿を持つ)」ではなく、この「多対多」に当てはまるなら、sync() は最強の武器になります!

`sync()` メソッドはなぜこんなに便利なのか?

結論から言うと、sync()「中間テーブルの状態を、渡した配列と全く同じ状態に同期してくれる」からです。

例えば、ある商品 (ID: 1) に関連するカテゴリIDを [1, 3, 5] にしたい場合を考えます。

従来の面倒な処理

sync() を使わない場合、以下のような複雑な処理が必要でした。

// 1. フォームから新しいカテゴリIDの配列を取得
$newCategoryIds = $request->input('category_list', []); // 例: [1, 3, 5]

// 2. 現在の商品を取得
$product = Product::find(1);

// 3. 現在関連付いているカテゴリIDを取得
$currentCategoryIds = $product->categories()->pluck('id')->toArray(); // 例: [1, 2, 4]

// 4. 削除すべきIDを計算 (現在にあって、新しいにない)
$detachIds = array_diff($currentCategoryIds, $newCategoryIds); // [2, 4]

// 5. 追加すべきIDを計算 (新しいにあって、現在にない)
$attachIds = array_diff($newCategoryIds, $currentCategoryIds); // [3, 5]

// 6. 削除実行
if (!empty($detachIds)) {
$product->categories()->detach($detachIds);
}

// 7. 追加実行
if (!empty($attachIds)) {
$product->categories()->attach($attachIds);
}

差分を計算して、detach() (削除) と attach() (追加) を呼び分ける…。非常に面倒ですね。

`sync()` を使った場合の処理

これが sync() を使うと、たった1行になります。

魔法の1行:

$product = Product::find(1);
$newCategoryIds = $request->input('category_list', []); // 例: [1, 3, 5]

// これだけ!
$product->categories()->sync($newCategoryIds);

この1行で、Laravelは裏側で以下の処理を自動的に実行してくれます。

  • 渡された配列 [1, 3, 5]含まれていない既存の関連 (例: ID 2, 4) を中間テーブルから削除 (detach) します。
  • 渡された配列 [1, 3, 5] のうち、まだ中間テーブルに存在しない関連 (例: ID 3, 5) を追加 (attach) します。
  • 渡された配列 [1, 3, 5] のうち、すでに存在する関連 (例: ID 1) はそのまま保持します。

まさに「同期」。必要なIDの配列を渡すだけで、中間テーブルを最新の状態にしてくれるのです。

具体的な使用例(テーブル・モデル・コントローラ)

「商品 (Product)」と「カテゴリ (Category)」の多対多リレーションを例に見てみましょう。

1. テーブル構成例

多対多リレーションには3つのテーブルが必要です。

`products` テーブル

id name created_at updated_at
1 すごい商品A

`categories` テーブル

id name created_at updated_at
1 家電
2 食品
3 書籍

`category_product` テーブル (中間テーブル)

これが「多対多」のキモです。Laravelの規約では、モデル名の単数形をアルファベット順に並べます (category_product)。

id product_id category_id
101 1 1
102 1 2

(この状態は「すごい商品A」が「家電」と「食品」に属していることを示します)

2. モデル (Model) の設定

belongsToMany を使ってリレーションを定義します。

app/Product.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
/**
* この商品が属するカテゴリ (多対多)
*/
public function categories()
{
// 'category_product' 中間テーブルを経由して
// 'App\Category' モデルに関連付く
return $this->belongsToMany('App\Category', 'category_product', 'product_id', 'category_id');

// 規約通りなら、引数は省略して以下でもOK
// return $this->belongsToMany('App\Category');
}
}

app/Category.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
/**
* このカテゴリに属する商品 (多対多)
*/
public function products()
{
return $this->belongsToMany('App\Product', 'category_product', 'category_id', 'product_id');
}
}

3. コントローラ (Controller) での更新処理

商品編集フォーム(カテゴリをチェックボックスで選択)からの更新処理です。

app/Http/Controllers/ProductController.php

<?php

namespace App\Http\Controllers;

use App\Product;
use Illuminate\Http\Request;

class ProductController extends Controller
{
/**
* 商品情報を更新します。
*/
public function update(Request $request, $id)
{
// 1. 更新対象の商品を取得
$product = Product::findOrFail($id);

// 2. 商品名など、他の情報を更新
$product->name = $request->input('name');
$product->save();

// 3. カテゴリ情報を同期
// フォームから 'category_list' という名前でカテゴリIDの配列が送られてくると仮定
// (例: [1, 3] ... "家電" と "書籍" が選択された)
// チェックボックスが空の場合も考慮し、デフォルト値 [] を指定
$categoryIds = $request->input('category_list', []);

$product->categories()->sync($categoryIds);

// ---
// もし sync([1, 3]) が実行されると、中間テーブルは...
// 
// 削除: (product_id: 1, category_id: 2) ... 食品
// 維持: (product_id: 1, category_id: 1) ... 家電
// 追加: (product_id: 1, category_id: 3) ... 書籍
// ---

return redirect()->route('products.show', $product->id)
             ->with('success', '商品を更新しました。');
}
}

これで完了です! フォームから送られてきたカテゴリIDの配列 [1, 3] だけで、中間テーブルが正しく更新されました。

`sync()` を使う上での重要すぎる注意点

sync() は非常に便利ですが、その動作特性を理解していないと予期せぬデータ削除につながる可能性があります。

1. `sync()` は多対多 (belongsToMany) 専用

sync() は中間テーブルを操作するためのメソッドです。1対多 (hasMany) リレーション(例: $user->posts())では使えません。「Laravel 1対多 更新」で検索した方は、まずご自身の設計が多対多(中間テーブルがあるか)かどうかを確認してください。

2. `sync()` は「削除」も行う

これが最大の注意点です。sync() は「同期」なので、渡した配列に含まれていないIDの関連は、容赦なく削除 (detach) します。

もし「既存の関連はそのままにして、新しいものだけ追加したい」場合は、sync() ではなく syncWithoutDetaching() を使います。

// [1, 3] を追加する。既存の [1, 2] があっても 2 は削除しない。
// 結果: [1, 2, 3] (重複は自動的に無視される)
$product->categories()->syncWithoutDetaching([1, 3]);

3. 空配列 [] を渡すと、すべて削除される

$product->categories()->sync([]); を実行すると、その商品の関連カテゴリがすべて削除されます。

これは、カテゴリ選択のチェックボックスが1つもチェックされなかった場合に便利です。コントローラの例で $request->input('category_list', []) としているのは、チェックが0件のときに null ではなく空配列 [] を渡し、すべての関連を削除する(=意図した動作)ためです。

4. 中間テーブルに追加カラムがある場合

もし中間テーブル category_productproduct_id, category_id 以外のカラム(例: display_order)がある場合、sync() の第2引数でキーと値のペアとして渡す必要があります。

// [カテゴリID => [追加カラム => 値]] の形式
$product->categories()->sync([
1 => ['display_order' => 10],
3 => ['display_order' => 20]
]);

まとめ

Laravelの sync() メソッドは、多対多 (belongsToMany) リレーションにおける中間テーブルの更新処理を、劇的に簡潔かつ安全にしてくれる強力な機能です。

Laravel 1対多 更新」で調べていた方も、もし対象が中間テーブルを持つ「多対多」の更新であれば、sync() こそが求めていた答えのはずです。

その「削除も伴う同期」という動作を正しく理解し、日々の開発をスピードアップさせましょう!

 
※参考にされる場合は自己責任でお願いします。