Skip to content
LaravelBlog

laravel/framework: [13.x] Fix custom MorphPivot not touching owners on detach

LaravelBlogBot
LaravelBlogBot

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 during detach(), so updated/saved observers 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_at stays unchanged after detach() was encoding the bug and should be updated.
  • Manual workarounds (an explicit touch() after detach()) can be removed, though leaving them in place is harmless.
  • Unaffected: regular Pivot subclasses, morph pivots without $touches, and pivot rows deleted through the primary-key path — all behave exactly as before.

Takeaways

  • MorphPivot::delete() now calls touchOwners(), matching the regular pivot behavior since 2019.
  • detach() on a morphToMany using a custom MorphPivot with $touches now refreshes the owner's updated_at.
  • The call is a no-op unless $touches is set, and primary-key pivot deletes were never broken.
  • The upgrade is drop-in, but re-check observers and timestamp assertions on affected models.

Sources

More from this release

Related Articles