Skip to content
LaravelBlog

laravel/framework: has() and whereHasMorph() now respect withTrashed() on morphTo

LaravelBlogBot
LaravelBlogBot

Contributed by xurshudyan

Part of the framework v13.35.0 release

Category: 9 · Bug fix

This release ships a focused fix for anyone combining soft deleting with polymorphic relations. Before this patch, calling withTrashed() on a morphTo() worked correctly when you actually loaded the relation, but the same modifier was silently ignored when the relation was used inside existence queries like has() and whereHasMorph(). That produced confusing behavior: $like->likeable happily returned a soft-deleted parent, while Like::has('likeable') reported zero matching rows. PR #61846 resolves this by replaying the relation's buffered query modifiers onto the existence query, so query-time checks now agree with eager and lazy loading.

Why the two paths disagreed

A morphTo() relation doesn't know its related model until runtime, because the type lives in a column on the parent row. To work around that, MorphTo buffers modifier calls — withTrashed(), where(), and friends — in an internal macro buffer, and replays them onto the concrete query as soon as the morph type is resolved. That's why loading the relation always worked as expected.

Existence queries take a completely different code path. has() and whereHasMorph() never load the relation; instead, the framework builds a temporary belongsTo relation for each concrete morph type via getBelongsToRelation() and merges constraints from the original relation. The problem: that conversion copied the query constraints but never replayed the macro buffer. The SoftDeletes global scope of the related model therefore stayed in place inside the exists subquery, filtering out trashed parents — even though the relation definition had explicitly opted into them.

What the patch changes

The fix is deliberately small and surgical:

  • MorphTo::replayMacros() is widened from protected to public.
  • getBelongsToRelation() now calls $relation->replayMacros($belongsTo->getQuery()) before merging constraints with mergeConstraintsFrom().

Because the whole buffer is replayed, this is not limited to withTrashed(). Any modifier you chain onto a morphTo() — a custom where(), ordering, whatever — will now be honored by has() and whereHasMorph() as well. The PR also adds an integration test, testWhereHasMorphWithTrashed, covering both has() and whereHasMorph() against a soft-deleted parent, alongside a plain relation without withTrashed() for comparison.

Before and after in code

Consider the setup from the new tests: a Like model with a polymorphic owner, where Post uses SoftDeletes.

class Like extends Model
{
    public function likeable()
    {
        return $this->morphTo();
    }

    public function likeableWithTrashed()
    {
        return $this->morphTo('likeable')->withTrashed();
    }
}

Now suppose Like #2 points at Post #3, which has been soft deleted:

// Loading was never broken:
$like->likeable; // the trashed Post

// Before the fix: 0 — the trashed parent was filtered out of the exists query
// After the fix: 1 — the buffered withTrashed() is now honored
Like::has('likeableWithTrashed')->count();
Like::whereHasMorph('likeableWithTrashed', Post::class)->count();

// Unchanged: without withTrashed(), trashed parents are still excluded
Like::has('likeable')->pluck('id'); // [1]

Upgrade impact

If you're upgrading from v13.34.0, there are no breaking API changes here: widening a method's visibility is additive, and no migrations, config changes, or model changes are required.

What you should review is the behavioral side. Any has(), doesntHave(), or whereHasMorph() query that walks a morphTo() defined with withTrashed() will now (correctly) return more rows than before. If you unknowingly relied on the old behavior to hide records pointing at soft-deleted parents, define a second relation without withTrashed() — exactly like the likeable / likeableWithTrashed pair above — and query that one instead. On the flip side, if you had worked around the bug by calling withTrashed() inside a whereHasMorph() closure or hand-rolling exists() joins, you can now simplify back to the relation definition you always meant to use.

Takeaways

  • morphTo()->withTrashed() is now consistently honored by has() and whereHasMorph(), matching how eager and lazy loading already behaved.
  • The root cause was the macro buffer in MorphTo never being replayed when building existence queries; replayMacros() is now invoked in getBelongsToRelation().
  • Expect query results that include soft-deleted parents after upgrading; adjust relation definitions if you depended on the old exclusion.
  • The fix also applies to other buffered modifiers on morphTo, not just withTrashed().
  • No breaking changes — this is a straight bug fix with new integration test coverage.

Sources

More from this release

Related Articles