2025-01-29
word count: 429
approx reading time: 2 mins
i discovered a cool Laravel trick that people don't seem to know about, so i'm making a small write-up on it for posterity.
imagine we have three models: User
, Team
, and Project
.
Users and Teams have a many to many relationship: multiple Users can be in the same Team, a User can be in multiple Teams.
Teams can have many Projects, but a Project belongs to only one Team.
in code:
// User.php class User extends Model { public function teams(): BelongsToMany { return $this->belongsToMany(Team::class); } } // Team.php class Team extends Model { public function users(): BelongsToMany { return $this->belongsToMany(User::class); } public function projects(): HasMany { return $this->hasMany(Project::class); } } // Project.php class Project extends Model { public function team(): BelongsTo { return $this->belongsTo(Team::class); } }
a common operation in this imagined scenario would be to get all Projects a User has access to through all of the teams they are in. we could write something like this:
// ...in User.php public function allProjects(): Builder { return Project::whereIn('team_id', $this->teams()->pluck('id')); }
it's not terrible, i guess.
it does let you get the projects, and it returns a Builder
so we can add further constraints.
but it's got a critical problem: it's not a relationship!
we can't use it in with
or withCount
, nor can we do any of the other cool things we can do with relationships.
we might try to write the following:
// incorrect! public function allProjects(): HasManyThrough { return $this->hasManyThrough( Project::class, Team::class, 'id', // foreign key on teams table 'team_id', // foreign key on projects table 'id', // local key on users table 'id' // local key on teams table ); }
but this doesn't work!
hasManyThrough
is meant for two hasMany
relationships in a row, or a hasOne
into a hasMany
, but not for belongsToMany
.
indeed, the following test fails, as the count of allProjects
is 2 and not the expected 6:
public function test_user_can_access_projects_through_teams(): void { // create a user and 3 teams with 2 projects each $user = User::factory() ->hasAttached( Team::factory() ->count(3) ->has(Project::factory()->count(2)) ) ->create(); $this->assertCount(6, $user->allProjects); }
it's simple: we just define a new many to many relationship between users and projects,
but reusing the team_user
table as the pivot table, and using team_id
as the related key on the projects
table, instead of id
:
public function allProjects(): BelongsToMany { return $this->belongsToMany( // related model related: Project::class, // pivot table table: 'team_user', // foreign key on pivot table referencing `parentKey` in the `users` table foreignPivotKey: 'user_id', // related key on pivot table referencing `relatedKey` in the `projects` table relatedPivotKey: 'team_id', // referenced key on the `users` table parentKey: 'id', // referenced key on the `projects` table relatedKey: 'team_id', ); }
compare with our definition of User::teams()
, expanded to show all default arguments:
// in User.php public function teams(): BelongsToMany { return $this->belongsToMany( related: Team::class, // <--- different table: 'team_user', foreignPivotKey: 'user_id', relatedPivotKey: 'team_id', parentKey: 'id', relatedKey: 'id', // <--- different ); }
here, relatedKey
points to the id
field of the teams
table, instead of pointing to the team_id
column of the projects
table.
if you want to think about the relationships graphically, we are essentially constructing a relationship that looks like this:
table: | user | | team_user | | project | column: | id | <------> | user_id team_id | <------> | team_id |
we are abusing the fact that for the team --hasMany--> project
relationship to work, projects
has to have a team_id
column, and that belongsToMany
lets us specify what column it should use as a related key.
or if you're more comfortable with the actual sql, this is what this relationship generates:
select * from "projects" inner join "team_user" on "projects"."team_id" = "team_user"."team_id" where "team_user"."user_id" = ?
our tests now pass!
and critically, it still works if we write it using loadCount
, showcasing that it works like a normal relationship!
$user->loadCount('allProjects'); $this->assertEquals(6, $user->all_projects_count);
as far as i'm aware, the only real downside is that the code is ugly and unintuitive.
it's probably less performant than running Project::whereIn('team_id', $this->teams()->pluck('id'))
,
but it's more generic, and it fits better in a Laravel codebase due to being an actual relationship.
but maybe i'm missing something! if you think i am, please do let me know, and i'll add an update to this post.