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.