skip to content

In Eloquent many-to-many relations, how do attach(), detach(), sync(), syncWithoutDetaching() and toggle() differ, and what does sync() delete?

level: middleimportance: must knowfreq 63%

answer

  1. all five write only pivot rows
  2. attach inserts without checking duplicates
  3. detach() with no argument clears all
  4. sync returns attached, detached, updated
  5. toggle flips; syncWithoutDetaching only adds

basics

~20 s

All five write pivot rows, never Tag rows. attach() inserts rows without checking for duplicates, detach() deletes them (every row when called without IDs), sync() makes the pivot match the given list exactly, syncWithoutDetaching() only adds, and toggle() flips each ID.

solid answer

~40 s

On a `belongsToMany` relation such as `$photo->tags()`, every method writes the pivot table (`photo_tag`) and never deletes a `Tag` row. `attach($ids, $attributes)` inserts pivot rows and does not check whether they already exist, so a repeat call creates a duplicate row or trips a unique index. `detach($ids)` deletes the matching pivot rows and returns how many it deleted; `detach()` with no argument deletes all of them. `sync($ids)` reads the current IDs, deletes every pivot row whose ID is missing from the list, inserts the new ones, updates pivot attributes you pass for existing ones, and returns `['attached' => [...], 'detached' => [...], 'updated' => [...]]`. `syncWithoutDetaching()` is `sync($ids, false)`: an idempotent add. `toggle()` detaches the IDs that are present and attaches the ones that are not. `updateExistingPivot($id, $attributes)` edits one pivot row in place.

code

php · 15 lines
php
<?php

use App\Models\Photo;

$photo = Photo::findOrFail($id);   // currently tagged 1, 2, 3

$photo->tags()->attach(5, ['added_by' => $userId]); // 1, 2, 3, 5
$photo->tags()->detach(1);                          // 2, 3, 5 (returns 1)
$photo->tags()->syncWithoutDetaching([2, 6]);       // 2, 3, 5, 6
$photo->tags()->toggle([3, 7]);                     // 2, 5, 6, 7

$changes = $photo->tags()->sync([2, 8]);            // 2, 8
// $changes: attached [8], detached [5, 6, 7], updated []

$photo->tags()->updateExistingPivot(2, ['added_by' => $userId]);

go deeper

for a junior

Recall that these methods are called on the relation method, touch only the pivot table, and that sync() leaves exactly the IDs you pass.

for a middle

Explain the order sync() follows, what each method returns, why attach() can duplicate rows, and how pivot attributes travel with IDs.

for a senior

Show where these calls bite in production: sync([]) wiping links, non-atomic multi-statement writes, and choosing syncWithoutDetaching() for idempotent adds.

for a principal

Weigh a replace-the-set API built on sync() against add and remove endpoints built on attach() and detach(), for auditability and concurrent edits.

## The setting: a pivot table between photos and tags On a photo-sharing site a `Photo` can carry many `Tag`s and a tag labels many photos. Eloquent models this as a **many-to-many** relation, declared with `belongsToMany`, backed by an **intermediate (pivot) table** such as `photo_tag` with `photo_id` and `tag_id` columns, and maybe extra columns such as `added_by`. Every write method discussed here is called on the **relation object** (`$photo->tags()`, with parentheses) and writes **pivot rows**. None of them creates or deletes a row in `tags` (the only other write is an optional timestamp touch, described below). Detaching the tag "sunset" from a photo leaves the tag itself in place for every other photo. ## The five write methods side by side | Method | What it does to `photo_tag` | Returns | |---|---|---| | `attach($ids, $attributes = [])` | Inserts one row per ID, with no existence check | nothing (`void`) | | `detach($ids = null)` | Deletes rows for the given IDs; with no argument, **all** rows for this photo | number of rows deleted | | `sync($ids)` | Deletes rows missing from the list, inserts new ones, updates attributes you pass for existing ones | `['attached', 'detached', 'updated']` | | `syncWithoutDetaching($ids)` | Same as `sync($ids, false)`: inserts missing rows, never deletes | same three-key array | | `toggle($ids)` | Detaches each given ID that is attached, attaches each that is not | `['attached', 'detached']` | A sixth method, `updateExistingPivot($tagId, ['added_by' => $userId])`, changes the extra columns of one existing pivot row and returns the number of rows updated. `syncWithPivotValues($ids, $values)` is `sync()` with the same pivot attributes applied to every ID. ## What sync() actually deletes `sync()` is the method interviewers probe, because its name hides a delete. Reading the framework source, it runs in this order: 1. It reads the IDs currently in the pivot for this parent. 2. If detaching is on (the default), it deletes every pivot row whose ID is **not** in your list. 3. It inserts a pivot row for each ID in your list that was not there before. 4. For an ID that was already attached and came with attributes (`[4 => ['added_by' => 9]]`), it updates that pivot row. 5. If anything changed, it touches parent timestamps that the models ask to be touched. So `sync([2, 4])` on a photo tagged 1, 2 and 3 leaves exactly rows 2 and 4. And `sync([])` with an empty list **deletes every tag link on the photo** — the method returns early only when the list is empty *and* detaching is off. That is the classic production surprise: a form that omits the tags field ends up wiping the photo's tags. ## Choosing between them - **Replacing the whole set** from a tag editor: `sync()`. It is the only one that expresses "after this call, exactly these". - **Adding a tag without caring whether it is already there**: `syncWithoutDetaching([$tagId])`. It checks the current set first, so a double click does not insert a second row. - **Adding a tag you know is new**: `attach()`. It is one INSERT and no read, but it trusts you: calling it twice inserts two rows, or fails with a constraint violation if the pivot has a unique index on `(photo_id, tag_id)`. - **Removing specific tags**: `detach([$tagId])`. Remember that `detach()` with no argument clears the lot. - **A "favourite this tag" button**: `toggle()` flips the state in one call. ## Return values are useful Because `sync()` and `toggle()` report what they changed, you can log an audit entry or clear a cache only for the tags that moved: ```php $changes = $photo->tags()->sync($tagIds); // ['attached' => [4], 'detached' => [1, 3], 'updated' => []] ``` The keys are cast to the related key type, so integer IDs come back as integers. ## Two details worth knowing - **Transactions:** these methods issue several statements and do not open a transaction themselves. Each has an `OrFail` variant (`attachOrFail`, `detachOrFail`, `syncOrFail`, `syncWithoutDetachingOrFail`, `toggleOrFail`) that wraps the work in a database transaction. - **The loaded collection does not update:** if `$photo->tags` was already loaded, it still holds the old tags after `attach()` or `sync()` until you reload the relation.

  • How do you store extra data such as who added a tag while syncing?
    Pass an array keyed by ID: `sync([4 => ['added_by' => $userId], 7])`. New IDs are inserted with those attributes; an existing ID that comes with attributes has its pivot row updated and appears in the `updated` key. For one value applied to every ID, `syncWithPivotValues([4, 7], ['added_by' => $userId])` does the same. The columns must also be declared with `withPivot()` on the relation to be read back.
  • Is sync() atomic?
    No. It runs a SELECT, a DELETE and one INSERT per new ID without opening a transaction, so a failure part-way leaves the deletions in place. `syncOrFail()` runs the same work inside `$connection->transaction()`, which rolls everything back if an exception is thrown. The other pivot writers have matching `OrFail` variants.
  • What does detach() do when you pass it an empty array rather than nothing?
    It returns 0 and deletes nothing. The source only removes every row when the argument is `null`, meaning no argument at all; an empty list parses to no IDs and short-circuits. `sync([])` behaves the opposite way and removes every link, which is why the two are easy to confuse.

saying these in an interview costs you the question

  • Saying detach() or sync() deletes the Tag records themselves
  • Believing attach() skips IDs that are already attached
  • Thinking sync([]) is a no-op that leaves existing tags alone
  • Claiming sync() runs inside a transaction automatically
  • Treating toggle() as an alias of syncWithoutDetaching()