Search by

codewiser / belongs-to-many

Cellard

Extended BelongsToMany for Laravel

Package info

github.com/C0deWiser/belongs-to-many

pkg:composer/codewiser/belongs-to-many

Statistics

Installs: 65

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.1 2026-10-06 09:01 UTC

This package is auto-updated.

Last update: 2026-10-06 09:02:58 UTC


README

A BelongsToMany relation connects two models through a pivot table. In simple cases, the pivot table holds just two columns — the foreign keys. In more complex cases, it holds additional columns that you want to use to constrain the relation.

The problem: when you use the whereHas method on a BelongsToMany relation, the callback receives a Builder instance instead of the Relation instance, so you can't use the wherePivot* methods.

Take a look at the difference. Here we get a Relation instance:

$user->organizations()->wherePivot('role', 'accountant');

But in whereHas, we get a Builder instance:

User::query()
    ->where('users.role', 'superuser')
    ->whereHas('organizations', fn(Builder $builder) => $builder
        ->where('organization_user.role', 'accountant')
    );

Here we're forced to use fully qualified column names to avoid ambiguity.

The solution is to hand the Relation instance to the callback, which is what the HasPivot trait does for custom builders.

With the trait, all *has* builder methods — whereHas, whereDoesntHave, etc. — pass the relation object to the callback instead of a Builder instance, so you can use any wherePivot* method to constrain the intermediate query.

The trait supports relations of all types — BelongsTo, HasMany, BelongsToMany, MorphTo, etc. The relation is handed over only when the callback explicitly declares a type accepting it; otherwise the callback keeps the default Builder instance.

Implementation

Give model a custom builder and apply the HasPivot trait to it:

use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
use Illuminate\Database\Eloquent\Model;

#[UseEloquentBuilder(UserBuilder::class)]
class User extends Model
{
    //
}
use Codewiser\Database\Eloquent\Traits\HasPivot;
use Illuminate\Database\Eloquent\Builder;

/**
 * @extends Builder<User>
 */
class UserBuilder extends Builder
{
    use HasPivot;
}

What the callback receives

Now the whereHas callback may receive the relation object — depending on the type you require for its first parameter. The argument is replaced only when that type accepts the relation instance, so an untyped callback keeps the Builder.

Passing the relation is safe, because it forwards calls to the underlying Builder. Require a plain Builder, or no type at all, and you get the default (and backward compatible) behaviour:

use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

Organization::query()->whereHas('users',
    // Requires Builder explicitly (default, backward compatible);
    // gets the related model's Builder.
    fn(Builder $builder) => $builder->where('organization_user.role', 'accountant')
);

Organization::query()->whereHas('users',
    // Requires BelongsToMany explicitly; gets the relation.
    fn(BelongsToMany $builder) => $builder->wherePivot('role', 'accountant')
);

Organization::query()->whereHas('users',
    // No type; gets the related model's Builder.
    fn($builder) => $builder->where('organization_user.role', 'accountant')
);

Organization::query()->whereHas('users',
    // A Builder contract; gets the relation.
    fn(BuilderContract $builder) => $builder->wherePivot('role', 'accountant')
);

Organization::query()->whereHas('users',
    // BelongsToMany is part of the union; gets the relation.
    fn(Builder|BelongsToMany $builder) => $builder->wherePivot('role', 'accountant')
);

Custom Intermediate Table Builder

If you define a custom intermediate table model, it has a query builder of its own. The extended BelongsToMany object provides a way to access it via the pivot method — a macro registered by this package's PivotServiceProvider:

$user->organizations()->pivot(
    fn(PivotBuilder $builder) => $builder->whereRole('accountant')
);

Organization::query()->whereHas('users',
    fn(BelongsToMany $builder) => $builder->pivot(
        fn(MyPivotBuilder $builder) => $builder->whereRole('accountant')
    )
);

Since Laravel v13.26.0 added closure support to wherePivot, the pivot method is deprecated:

$user->organizations()->wherePivot(
    fn(MyPivotBuilder $builder) => $builder->whereRole('accountant')
);

Organization::query()->whereHas('users',
    fn(BelongsToMany $builder) => $builder->wherePivot(
        fn(MyPivotBuilder $builder) => $builder->whereRole('accountant')
    )
);

Notes

Everything above applies to relations of all types: BelongsTo, HasOne, HasMany, HasOneThrough, HasManyThrough, BelongsToMany, MorphToMany (and its inverse), MorphMany, MorphOne and MorphTo.

Handing the relation over to the constraint closure is not a new idea: Builder::with() and Model::load() already do it — their closures receive the relation instance, which forwards calls to the underlying Builder. whereHas() now behaves the same way:

User::query()->with('organizations',
    fn(BelongsToMany $relation) => $relation->wherePivot('role', 'accountant')
)->get();

$user->load(['organizations' =>
    fn(BelongsToMany $relation) => $relation->wherePivot('role', 'accountant')
]);