elvisisking's avatar

PHPStan custom query builder usage in whereHas

I'm struggling a bit with phpstan. I'm using laravel 11.45 with phpstan 2.1.22 and larastan 3.6.0. I'm wondering if there is a way to support phpstan to properly understand the situation before I give up and ignore the error.

I have the following query in a controller:

        $disputesWon = $user->productPurchases()
            ->whereHas('dispute', fn ($q) => $q->whereWonBy($user))
            ->count();

where the productPurchases relation is:

    /**
     * @return HasMany<ProductPurchase, $this>
     */
    public function productPurchases(): HasMany
    {
        return $this->hasMany(ProductPurchase::class, 'buyer_id', 'id');
    }

and the dispute relation is:

    /**
     * @return BelongsTo<Dispute, $this>
     */
    public function dispute(): BelongsTo
    {
        return $this->belongsTo(Dispute::class);
    }

With the $q unannotated as above phpstan gives this error:

phpstan: Call to an undefined method Illuminate\Database\Eloquent\Builder<TRelatedModel of Illuminate\Database\Eloquent\Model>::whereWonBy().

if instead I annotate it as follows:

        $disputesWon = $user->productPurchases()
            ->whereHas('dispute', fn (DisputeBuilder $q) => $q->whereWonBy($user))
            ->count();

I get this error:

phpstan: Parameter #2 $callback of method Illuminate\Database\Eloquent\Builder<Domain\Purchases\Models\ProductPurchase>::whereHas() expects (Closure(Illuminate\Database\Eloquent\Builder<Domain\Purchases\Models\Dispute>): mixed)|null, Closure(Domain\Purchases\Builders\DisputeBuilder): Domain\Purchases\Builders\DisputeBuilder given.

In my dispute class:

    public function newEloquentBuilder($query): DisputeBuilder
    {
        return new DisputeBuilder($query);
    }

And the DisputeBuilder class:

/**
 * @extends Builder<Dispute>
 *
 * @mixin Dispute
 */
class DisputeBuilder extends Builder
{
    public function whereWonBy(User $user): static
    {
        return $this->where('winner_id', $user->id);
    }

    public function whereNotWonBy(User $user): static
    {
        return $this->where('winner_id', '!==', $user->id);
    }
}
0 likes
3 replies
LaryAI's avatar
Level 58

You're running into a common static analysis issue with custom Eloquent builders and PHPStan/Larastan. The core of the problem is that Laravel will use your custom DisputeBuilder at runtime, but the type signature for the closure in whereHas expects a standard Illuminate\Database\Eloquent\Builder<Dispute>, not your custom builder. PHPStan is strict about this.

How to fix this for PHPStan/Larastan:

1. Use @phpstan-param annotation on the closure

You can help PHPStan understand that your closure will receive your custom builder by adding a @phpstan-param annotation. For example:

$disputesWon = $user->productPurchases()
    ->whereHas('dispute',
        /**
         * @phpstan-param \Domain\Purchases\Builders\DisputeBuilder $q
         */
        fn ($q) => $q->whereWonBy($user)
    )
    ->count();

This tells PHPStan that $q is a DisputeBuilder, and it will stop complaining about the missing method.


2. Use @mixin on the Dispute model

Make sure your Dispute model has the correct @mixin annotation so that PHPStan knows it uses your custom builder:

/**
 * @mixin \Domain\Purchases\Builders\DisputeBuilder
 */
class Dispute extends Model
{
    // ...
}

3. (Optional) Add a Larastan extension

If you want to go further, you can write a Larastan extension to teach PHPStan about your custom builder, but for most cases, the annotation above is sufficient.


4. Don't change the closure typehint

Do not typehint the closure as fn (DisputeBuilder $q) => ... directly, as this will always cause a PHPStan error. Use the annotation instead.


Summary

Use this pattern:

$disputesWon = $user->productPurchases()
    ->whereHas('dispute',
        /**
         * @phpstan-param \Domain\Purchases\Builders\DisputeBuilder $q
         */
        fn ($q) => $q->whereWonBy($user)
    )
    ->count();

And add @mixin to your Dispute model:

/**
 * @mixin \Domain\Purchases\Builders\DisputeBuilder
 */
class Dispute extends Model
{
    // ...
}

This will resolve the PHPStan error and allow you to use your custom builder methods in relationship queries.

elvisisking's avatar

Thanks for the quick answer. I did add the @mixin on your suggestion and I added the @phpstan-param but sadly, it gives the same error as the unannotated version:

phpstan: Call to an undefined method Illuminate\Database\Eloquent\Builder<TRelatedModel of Illuminate\Database\Eloquent\Model>::whereWonBy().
elvisisking's avatar

I find an example of updated Custom Query Builder syntax in the larastan docs: https://github.com/larastan/larastan/blob/3.x/docs/features.md#custom-model-builders

I updated my Builder to be:

/**
 * @extends Builder<Dispute>
 *
 * @mixin Dispute
 */
class DisputeBuilder extends Builder
{
    /**
     * @return $this
     */
    public function whereWonBy(User $user): static
    {
        $this->where('winner_id', $user->id);

        return $this;
    }

    /**
     * @return $this
     */
    public function whereNotWonBy(User $user): static
    {
        $this->where('winner_id', '!==', $user->id);

        return $this;
    }
}

And I define it in my model:

 * @mixin DisputeBuilder
 */
class Dispute extends BaseModel
{
    /** @use HasBuilder<DisputeBuilder> */
    use HasBuilder;

    protected static string $builder = DisputeBuilder::class;

But I get the same error:

/Users/jasons/market/market-app/app/Http/Web/Controllers/IndexController.php:73:Call to an undefined method Illuminate\Database\Eloquent\Builder<TRelatedModel of Illuminate\Database\Eloquent\Model>::whereWonBy().

Please or to participate in this conversation.