Courses

Laravel 12 Eloquent: Expert Level

Advanced BelongsToMany with Extra Pivot Table Features

Summary of this lesson:
- Working with pivot table timestamps
- Adding custom pivot table fields
- Implementing pivot table queries
- Customizing pivot attribute access

Let's talk about a belongs-to-many, also called many-to-many relationships, but on a deeper level. What are other options besides the foreign keys columns in the pivot table?


This is a typical pivot table between a project and a user. A project may belong to many users.

Schema::create('project_user', function (Blueprint $table) {
$table->foreignId('project_id')->constrained();
$table->foreignId('user_id')->constrained();
});

To show users for each project, you typically get the projects with user relations and do a foreach loop.

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class Project extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
}
$projects = Project::with('users')->get();
 
foreach ($projects as $project) {
print $project->id . ': ' . $project->title . ' (';
 
foreach ($project->users as $user) {
print $user->email .'; ';
}
 
print ')<hr />';
}

This is a typical default simple belongs-to-many relation.


Adding Timestamps to Pivot

What can we add to the pivot? For example, you want to know when that record was added. By default, pivot tables are without timestamps. To enable timestamps, we must first add columns to the migration.

Schema::create('project_user', function (Blueprint $table) {
$table->foreignId('project_id')->constrained();
$table->foreignId('user_id')->constrained();
$table->timestamps();
});

But that wouldn't be enough. Timestamps, by default, wouldn't be set in a pivot table. To enable setting timestamps in the relationship belongs-to-many, you need to define withTimestamps().

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class Project extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withTimestamps();
}
}

I have re-seeded the database, and now the timestamps are filled.

How do we show the created_at from the pivot table? On the object, first, you must use the pivot, which is the intermediate table, and then call the column.

$projects = Project::with('users')->get();
 
foreach ($projects as $project) {
print $project->id . ': ' . $project->title . ' (';
 
foreach ($project->users as $user) {
print $user->email .' <i>' . $user->pivot->created_at . '</i>; ';
}
 
print ')<hr />';
}

Now, as a result, we can see email and timestamp.


Adding Custom Fields to Pivot

Similarly, you can add any field, not just the timestamps. Again, it was a similar scenario—migrations, then model, and then where do you use that? For example, we can add if it is active.

Schema::create('project_user', function (Blueprint $table) {
$table->foreignId('project_id')->constrained();
$table->foreignId('user_id')->constrained();
$table->boolean('is_active')->default(false);
$table->timestamps();
});

Next, you define withPivot() in the belongs to many relationships and provide all the columns.

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class Project extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withPivot('is_active')->withTimestamps();
}
}

Then, where you want to show the value, you again use pivot on the object and then call the column.

$projects = Project::with('users')->get();
 
foreach ($projects as $project) {
print $project->id . ': ' . $project->title . ' (';
 
foreach ($project->users as $user) {
print $user->email .' <u>' . $user->pivot->is_active . '</u> <i>' . $user->pivot->created_at . '</i>; ';
}
 
print ')<hr />';
}

As a result, some records are active, and some aren't.


Query By Pivot Fields

What if you want to query by the pivot field is_active? You can do this on the Eloquent query whereHas(), but also you can define a specific relationship.

For example, we can define a relation only for is_active true. When describing the relation, instead of withPivot(), we must use the wherePivot() method.

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class Project extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withPivot('is_active')->withTimestamps();
}
 
public function activeUsers(): BelongsToMany
{
return $this->belongsToMany(User::class)->wherePivot('is_active', true)->withTimestamps();
}
}

Then, instead of eagerly loading the users relation, we must use activeUsers.

$projects = Project::with('activeUsers')->get();
 
foreach ($projects as $project) {
print $project->id . ': ' . $project->title . ' (';
 
foreach ($project->activeUsers as $user) {
print $user->email .' <u>' . $user->pivot->is_active . '</u> <i>' . $user->pivot->created_at . '</i>; ';
}
 
print ')<hr />';
}

As a result, only users with is_active true on a pivot table are shown.


Rename "pivot"?

Another possibility is to rename the word pivot. What if you don't like the phrase pivot, or it's not 100% clear for you and your team, and you want to rename it for clarity? In the relationship, you may define as.

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class Project extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withPivot('is_active')->withTimestamps();
}
 
public function activeUsers(): BelongsToMany
{
return $this->belongsToMany(User::class)
->as('active_user')
->wherePivot('is_active', true)
->withPivot('is_active')->withTimestamps();
}
}

Then, when showing data, instead of pivot for the intermediate table, you use active_user.

$projects = Project::with('activeUsers')->get();
 
foreach ($projects as $project) {
print $project->id . ': ' . $project->title . ' (';
 
foreach ($project->activeUsers as $user) {
print $user->email .' <u>' . $user->active_user->is_active . '</u> <i>' . $user->active_user->created_at . '</i>; ';
}
 
print ')<hr />';
}

The result stays the same. It still works. To be honest, I've never used this feature. I like the word pivot, and it describes the table best, but it is possible to rename it if you want.


Code for this lesson can be found on GitHub.

Previous: Advanced Polymorphic: Many-to-Many
avatar

Hello, i think in first function activeUsers() missing ->withPivot('is_active')

avatar
You can use Markdown
avatar
Mustafa Selman Yıldırım

In case of a single-value is_active column, a separate relation seems the better option. What if we have a multiple-value column on the pivot table and we want to filter with whereIn()? It doesn't seem reasonable to create different relations for every possible value. You've mentioned that whereHas() can be used. How do we do that? Is there any other eloquent ways?

avatar

It is not only reasonable to create columns for each potential value, but also beneficial.

When searching inside a JSON or some other multi-value column - you are creating performance issues. And using single value columns - you are avoiding them.

Now about the whereHas() there is wherePivotIn() and other wherePivot() methods available

avatar
Mustafa Selman Yıldırım

I apologize for any confusion caused by my earlier question.

By single-value column, I mean a column with a boolean value, such as is_active (as in the example above). By multi-value column, I mean a column that can take one value among several possible options, such as country_code. For instance, this column may have a value like us, uk, ca, or de, but not multiple codes simultaneously. Since the list of possible values can be extensive, it doesn't seem practical to create separate relationships for each of them.

However, this is just background information.

My main question is: I don't want to create separate relationships like activeUsers. Instead, I want to query dynamically with arbitrary values. I've read above that this should be possible using whereHas, but I couldn't find an example. When I tried, I wasn't able to make it work.

Here’s the scenario:

I have two models: User and Ability. There’s a pivot table between them that includes a column in_which_country. I want to query users based on both the name column of the abilities table and the in_which_country column of the pivot table (ability_user).

I assumed the following syntax should work, but it fails:

$users = User::whereHas('abilities', function ($query) {
    $query->where('name', 'send_feed');
})->whereHas('abilities', function ($query) {
    $query->wherePivotIn('in_which_country', ['us', 'ca']);
})->get();
avatar

Okay, this makes it more clear :)

So to filter this, in theory you could do:

$users = User::whereHas('abilities', function ($query) {
    $query->where('name', 'send_feed');
})->whereHas('abilities', function ($query) {
    $query->where('user_abilities.in_which_country', ['us', 'ca']);
})->get();

This should trigger the pivot table. Or, you could install debugbar and see the query it generates. From there it would be easier to work out what needs to be done ;)

avatar

ps. It's all about table/column naming. Laravel relationship via pivot table loads that pivot table too. So it can be queried by prefixing the column with table name

avatar
Mustafa Selman Yıldırım

Thank you very much! I think I got a bit too fixated on the wherePivotIn method. I guess we should use whereIn instead of where for handling multiple values (['us', 'ca']). Additionally, specifying the table name doesn’t seem necessary unless there’s ambiguity, such as when both tables have columns with the same name. For future reference, I can confirm that the following works:

$users = User::whereHas('abilities', function ($query) {
    $query->where('name', 'send_feed');
})->whereHas('abilities', function ($query) {
    $query->whereIn('in_which_country', ['us', 'ca']);
})->get();
avatar
You can use Markdown
avatar
You can use Markdown