A Laravel photo site's tag editor calls $photo->tags()->sync($request->input('tags', [])); why can tags vanish or half-update, and how do you harden it?
answer
- missing field means empty list
- empty list detaches everything
- several statements, no transaction
- syncOrFail wraps a transaction
- plain pivots fire no model events
basics
~20 sA missing tags field becomes [], and sync([]) detaches every tag. sync() also runs several statements without a transaction, so a bad ID can fail after links are deleted. Sync only when the field is present, validate IDs, use syncOrFail().
solid answer
~50 s`$request->input('tags', [])` returns an empty array when the form omits the field — a form without a tags widget, an API client doing a partial update — and `sync([])` deletes every pivot row for the photo. Only call `sync()` when the field is actually present, and use `syncWithoutDetaching()` or `detach()` for add-only or remove-only edits. Next, `sync()` issues a SELECT, a DELETE and one INSERT per new ID without opening a transaction; a tag ID that violates the foreign key fails after the DELETE has already run. Validate that every ID exists, and call `syncOrFail()`, which wraps the work in a transaction. Add a unique index on `(photo_id, tag_id)` so a concurrent double submit cannot duplicate a link. Finally, a plain pivot is written with bulk queries and fires no model events; if you need audit hooks, use the returned `attached`/`detached` arrays or a custom pivot class.
code
php · 26 lines<?php
use App\Models\Photo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class PhotoController
{
public function update(Request $request, Photo $photo)
{
$data = $request->validate([
'title' => ['sometimes', 'string', 'max:120'],
'tags' => ['sometimes', 'array'],
'tags.*' => ['integer', 'exists:tags,id'],
]);
$changes = DB::transaction(function () use ($photo, $data) {
$photo->update(array_intersect_key($data, ['title' => true]));
return array_key_exists('tags', $data)
? $photo->tags()->sync($data['tags'])
: null;
});
// $changes['attached'] / ['detached'] feed the audit log
}
}go deeper
Recall that sync() leaves exactly the IDs you pass, so an empty list removes every tag from the photo.
Explain the statements sync() runs, why they are not atomic, and what syncOrFail() and the returned arrays give you.
Diagnose the wiped-tags report from the request shape, harden with presence checks, validation, transactions and a unique index, and log the diff.
Decide whether tag editing is a replace-the-set API or add and remove operations, weighing concurrency, audit needs and client simplicity.
## The code under suspicion ```php public function update(Request $request, Photo $photo) { $photo->update($request->only('title', 'caption')); $photo->tags()->sync($request->input('tags', [])); } ``` It looks harmless, and it passes the happy-path test. In production it produces three kinds of bug reports: "all my tags disappeared", "I got an error and some tags were gone anyway", and "the same tag shows twice". ## Failure 1: the empty list wipes the photo `sync()` makes the pivot table match the list **exactly**. The framework's `sync()` returns early only when the list is empty **and** detaching is off; with the default `$detaching = true`, an empty list proceeds to delete every current row. `$request->input('tags', [])` returns `[]` whenever the field is **absent**, which happens when: - a second form (say, a caption-only quick edit) posts to the same endpoint; - a mobile client sends a partial update with only the title; - the tag widget fails to render and submits nothing. Fixes, in order of preference: 1. Call `sync()` **only when the field is present** (for example behind `$request->has('tags')`), and treat "present but empty" as a deliberate clear. 2. Give partial edits their own operations: `syncWithoutDetaching([$id])` to add, `detach([$id])` to remove. 3. Validate the field as a present array of existing tag IDs before it reaches the model. ## Failure 2: half-applied changes `sync()` runs several statements: 1. `SELECT` the current tag IDs from `photo_tag`. 2. `DELETE` the rows missing from the new list. 3. `INSERT` one row per new ID (and `UPDATE` rows whose pivot attributes you passed). No transaction wraps them. If an insert fails — a tag ID that no longer exists and violates the foreign key, for instance — the deletions from step 2 have already happened. `syncOrFail()` runs the same method inside `$connection->transaction()`, so any exception rolls back the whole change. `attachOrFail`, `detachOrFail`, `syncWithoutDetachingOrFail` and `toggleOrFail` exist for the same reason. If the photo's own `update()` must succeed or fail together with the tags, wrap both in one transaction instead. ## Failure 3: concurrent editors Two editors saving at the same moment each read the current set, compute their own diff and write. Without protection: - both may insert the same new tag, giving a **duplicate pivot row**; - the last writer wins, silently discarding the other's changes. A **unique index on `(photo_id, tag_id)`** turns the duplicate into an error you can catch and retry, and a transaction keeps each sync all-or-nothing. Whether you also lock the photo row or reject stale edits is a product decision. ## Failure 4: the audit hook that never fires Teams often add a model observer expecting "tag added" events. With a plain pivot, `attach()` inserts with a bulk query and `detach()` deletes with a query, so **no Eloquent model event fires for each link** that is added or removed. Two reliable options: | Need | Approach | |---|---| | Log what changed | Use the array `sync()` returns: `['attached' => [...], 'detached' => [...], 'updated' => [...]]` | | Per-link model events | Declare a custom pivot class on the relation; attach then saves pivot models and detach deletes them one by one, so their events fire | The custom pivot trades speed for hooks: it writes row by row. ## Hardened version A hardened controller keeps each concern visible: presence check, validated input, one transaction, a returned diff for auditing. A stale `$photo->tags` collection loaded earlier in the request is still stale afterwards; reload the relation if the response renders it. ## A review checklist for any sync() call - Where does the list come from, and can it be empty by accident rather than by intent? - Are the IDs validated against the related table, or does a foreign key at least reject unknown ones? - Does anything else in the request need to commit or roll back with the sync? - Is there a unique index on the pivot pair? - Does anyone rely on events or on the photo's cached `tags` after the call? A sync that survives those five questions is safe to ship; one that fails any of them is the source of the next "my tags disappeared" ticket.
- When is syncOrFail() not enough on its own?When other writes must succeed or fail with the tag change. `syncOrFail()` opens a transaction around the sync only; if the photo's title update runs outside it and the sync then fails, the title change stays committed. Wrap both writes in one `DB::transaction()` call, which nests safely with the inner transaction.
- How would you log exactly which tags an editor added and removed?Store the array that `sync()` returns: its `attached` and `detached` keys list the tag IDs that changed and `updated` lists rows whose pivot attributes changed. That works with a plain pivot, which fires no model events. If per-link hooks are needed elsewhere, a custom pivot class makes attach and detach go through pivot model saves and deletes, at the cost of one query per row.
saying these in an interview costs you the question
- Saying sync([]) leaves existing tags untouched
- Assuming sync() is wrapped in a transaction by default
- Expecting a model event for every pivot link that sync() writes
- Relying on sync() to reject tag IDs that do not exist
- Treating a missing field and an empty field as the same intent