laravel/framework: [13.x] Fix custom MorphPivot not touching owners on detach
Contributed by mrjavadseydi
Part of the framework v13.35.0 release
If your application attaches a custom MorphPivot class with a $touches property to a morphToMany relation, detach() has been silently skipping the touch of the related "owner" records. PR #61812 adds the missing touchOwners() call to MorphPivot::delete(), so morph pivots now refresh their owners' updated_at timestamps exactly like regular pivots do. For anyone upgrading from v13.34.0 there is nothing to migrate, but it is a deliberate behavior change: detaching through an affected relation will now write new timestamps — and fire the owner model's normal touch events — where it previously did nothing.
What changed
MorphPivot::delete() is essentially a copy of the pivot deletion logic specialized for the morph case. The regular pivot version calls touchOwners() before deleting a pivot row that has no primary key; the morph copy never did. The fix adds that single call in the same position, right after the deleting event guard:
// src/Illuminate/Database/Eloquent/Relations/MorphPivot.php (excerpt)
$this->touchOwners(); // ← added in #61812
$query = $this->getDeleteQuery();
$query->where($this->morphType, $this->morphClass);
When the pivot model's $touches array is empty, the call is a no-op, so the majority of setups see no difference. A new integration test covers a custom morph pivot on a table without an id column and asserts the owner's updated_at is refreshed on detach().
Where the bug came from
The regression has a long tail. touchOwners() was added to the shared pivot delete() logic back in 2019 so that non-incrementing pivot rows would still touch their owners when removed. Later, #30229 brought the deleting/deleted event dispatching to MorphPivot::delete() by copying that method — but the copy didn't include the touch call. Rows that do have a primary key were never affected, because that path goes through Model::delete(), which already calls touchOwners() itself. Only the key-less morph pivot path was left behind.
Before and after
The affected setup is a morphToMany that ->uses() a MorphPivot subclass defining $touches, backed by a pivot table without an id column:
class Post extends Model
{
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable')->using(Taggable::class);
}
}
class Taggable extends MorphPivot
{
protected $touches = ['tag'];
public function tag()
{
return $this->belongsTo(Tag::class);
}
}
Schema::create('taggables', function (Blueprint $table) {
$table->morphs('taggable'); // taggable_type + taggable_id
$table->foreignId('tag_id');
// no $table->id() — this is the path that hits MorphPivot::delete()
});
On v13.34.0, detaching does not touch the tag:
$post->tags()->detach($tag);
$tag->refresh()->updated_at; // unchanged — the bug
After the fix, the tag's timestamp is refreshed, matching what a regular Pivot with $touches has done since 2019:
$post->tags()->detach($tag);
$tag->refresh()->updated_at; // now updated, same as a plain Pivot
Why it matters
updated_at is load-bearing in most applications: cache invalidation, "last modified" syncs, search reindexing, and optimistic UI refreshes commonly hang off it. With this bug, a model whose morph-pivot row was detached could keep a stale timestamp indefinitely, forcing workarounds like manually calling $tag->touch() after detach(). It also removes a quiet inconsistency between Pivot and MorphPivot that only surfaces when you use a custom pivot class with $touches — exactly the kind of surprise that costs an afternoon to debug.
Upgrade impact
The fix applies automatically once you upgrade past v13.34.0, but review the following:
- Affected owner models are now saved through their normal
touch()path duringdetach(), soupdated/savedobservers and listeners on those models will fire where they previously did not. Make sure they are safe to run in that context. - Any test asserting that
updated_atstays unchanged afterdetach()was encoding the bug and should be updated. - Manual workarounds (an explicit
touch()afterdetach()) can be removed, though leaving them in place is harmless. - Unaffected: regular
Pivotsubclasses, morph pivots without$touches, and pivot rows deleted through the primary-key path — all behave exactly as before.
Takeaways
MorphPivot::delete()now callstouchOwners(), matching the regular pivot behavior since 2019.detach()on amorphToManyusing a customMorphPivotwith$touchesnow refreshes the owner'supdated_at.- The call is a no-op unless
$touchesis set, and primary-key pivot deletes were never broken. - The upgrade is drop-in, but re-check observers and timestamp assertions on affected models.