Skip to content

MixedMethodCall on $this->belongsTo(...)->withoutGlobalScopes() chain in relation methods #913

@alies-dev

Description

@alies-dev

Summary

Psalm reports MixedMethodCall when chaining withoutGlobalScopes() directly onto a belongsTo() call inside a relationship definition. The receiver of withoutGlobalScopes() (the BelongsTo returned by belongsTo()) is treated as mixed, breaking the fluent chain.

Versions

  • psalm/plugin-laravel: dev-master @ 9304208713a57542c08b14ed12e073cddbef978f (composer constraint ^4.0@RC)
  • vimeo/psalm: 7.0.0-beta19
  • psalm/plugin-phpunit: 0.20.1
  • laravel/framework: v12
  • PHP 8.5

Reproducer

namespace Domain\Cart\Models;

use Domain\Customer\Models\Customer;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

final class AbandonedCart extends Model
{
    /** @return BelongsTo<Customer, self> */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class)->withoutGlobalScopes();
    }
}

Reported error:

ERROR: MixedMethodCall
at src/Cart/Models/AbandonedCart.php:40:51
Cannot determine the type of the object on the left hand side of this expression (see https://psalm.dev/015)
        return $this->belongsTo(Customer::class)->withoutGlobalScopes();

The column points at ->withoutGlobalScopes(). Psalm has lost the type of the $this->belongsTo(Customer::class) receiver.

Expected behaviour

belongsTo() is stubbed (in stubs/common/Database/Eloquent/Concerns/HasRelationships.phpstub) to return \Illuminate\Database\Eloquent\Relations\BelongsTo<TRelatedModel, $this>, and withoutGlobalScopes(?array $scopes = null): $this is declared in the Builder stub. Via the Relation's @mixin Builder<TRelatedModel>, the chain should resolve to BelongsTo<Customer, AbandonedCart> so the method's @return BelongsTo<Customer, self> is satisfied without a Mixed cascade.

Notes / context

  • Likely the same family as Support hasMany(...)->orderBy() #190 (Support hasMany(...)->orderBy()), which was fixed in Add MethodForwardingHandler for Relation method forwarding #642 by adding MethodForwardingHandler. That handler covers QueryBuilder-only methods that come in through __call (Path 2: orderBy, limit, groupBy, etc.) and Builder-mixin methods (Path 1). withoutGlobalScopes() is declared directly in the Builder stub with @return $this, so it's a Path 1 / mixin-interception candidate — but the chain still falls back to Mixed at the immediate belongsTo() receiver, not just at later links.
  • Workaround: assign the relation to a typed local first, then call the chained method:
    /** @var BelongsTo<Customer, self> $relation */
    $relation = $this->belongsTo(Customer::class);
    return $relation->withoutGlobalScopes();
    Forcing the type with the intermediate variable resolves the error, which confirms the regression is in how the chained call infers the receiver type rather than in the Builder stub's withoutGlobalScopes() signature itself.

I haven't dug further into whether the gap is in MethodForwardingHandler's mixin-interception path, in the stubbed belongsTo() @return ... <TRelatedModel, $this> template resolution under chaining, or in core Psalm 7's handling of $this through @mixin. Happy to provide more debug info if helpful.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions