Refactoring N:N Relationships in Laravel

Refactoring many-to-many relationships in Laravel to create a more expressive codebase that we can talk about.

Problem

It's common to start a new project with the idea of keeping things simple, following framework conventions, and avoiding premature abstractions. In fact, it's definitely advisable to keep doing this.

But at some point, your project may reach an adolescent phase, where you need to change things if you want to promote the long-term health of the project.

N:N relationships can be a rather juicy area in need of such changes.


Setup

The Eloquent ORM provides ways for us to easily manage many-to-many relationships by defining the specific relationship on one or both of the models involved. The ORM's take care of the join table management for us.

I have been working on a Laravel project for some time now, where we have a simple many-to-many relationship between organizations and users.

You may notice that it doesn't sound quite right saying that an organization belongs to users, this is a smell. Though, many apps are setup this way.


class Organization extends Model
{
    public function users()
    {
        $this->belongsToMany(User::class);
    }
}

class User extends Model
{
    public function organizations()
    {
        $this->belongsToMany(Organization::class);
    }
}

This relationship is mapped through a table named organization_user, and so now it is easy to add and remove users from organizations. For example, convenient methods exist for this purpose.


$organization->users()->attach($user);

$organization->users()->detach($user);

$organization->users()->sync($userIds);

Now let's imagine we need to add some additional data to the join table, such as a role column.

No problem, at least in Laravel with Eloquent, we can still use the above convenience methods like so:


$organization->users()->attach($user, ['role' => 'supervisor']);

$organization->users()->detach($user);

$organization->users()->sync($userIds); ❌

Oops, we can't use the BelongsToMany::sync() method anymore. Not a major problem, we can still manage this ourselves.

But let's look at how we consume the role from the join table. Eloquent does something clever and allows us to access the role via the pivot property (join table).


foreach ($user->organizations as $organization) {
    echo $organization->pivot->role; // 'supervisor'
}

Eloquent also allows you to rename the property used to access this middle table, which makes the code look a little better:


foreach ($user->organizations as $organization) {
    echo $organization->org_user->role; // 'supervisor'
}

Critiques

While all of this is fine, and does work, it has always made me uncomfortable. First reason being static analysis. PHPStan and Larastan don't know anything about the pivot property. It will always report errors indicating that you are accessing a property which doesn't exist. This makes sense, because the static analysis doesn't necessarily have all the context needed to determine this.

Secondly, we are effectively consuming a table called organization_user and it has some properties on it which give it context, such as role or created_at which tell us something about the relationship.

As a developer working with this regularly, I don't have many issues understanding this relationship. But there is a major problem when my colleagues and I talk about it. The phrase "organization user" is pretty vague. Is it a user? Is it a relationship?

It's difficult to know if everybody is talking about the same thing, and it's extremely difficult to make changes to how the relationships function if we aren't using the same (or very descriptive) language to talk about things.


Solutions

When faced with issues like this, it's a good time to start thinking about the language used to describe these complex relationships. In our example, a good word to describe the relationship between organizations and users, may be memberships.

And while we could rename the property pivot to membership, allowing us to write this:


foreach ($user->organizations as $organization) {
    echo $organization->membership->role; // 'supervisor'
}

This is already much better, and even suggests to us that there is a concept in our domain which we are indirectly modelling.

However, it is not as expressive as it could be. It doesn't tell us exactly where the "membership" comes from, we have to rely on our knowledge of Eloquent, and the context in our application. Also, our static analysis tooling would agree with that sentiment.


I'd like to propose creating an Eloquent model called Membership, promoting the concept of memberships to a first class citizen within our application, and getting rid of the no-longer-convenient N:N relationships and replacing them with N:1 instead.


class User extends Model
{
    public function memberships()
    {
        return $this->hasMany(Membership::class);
    }
}

class Membership extends Model
{
    public function organization()
    {
        return $this->belongsTo(Organization::class);
    }
}

When consuming such an association, we would write something like:


foreach ($user->memberships as $membership) {
    echo $membership->organization->title; // Laravel
    echo $membership->role; // 'supervisor'
}

On top of this, Eloquent provides a way for us to create custom collections to represent collections of models. If we were to apply this pattern to our memberships, we could write methods such as hasMembershipAt and hasSupervisionMembershipAt:


use Illuminate\Database\Eloquent\Collection;

class MembershipsCollection extends Collection
{
    public function existsAt(Organization $organization)
    {
        return $this->contains(
            fn ($membership) => $membership->organization->is($organization)
        );
    }

    public function hasSupervisionMembershipAt(Organization $organization)
    {
        return $this->contains(
            fn ($membership) => $membership->organization->is($organization) && $membership->role === 'supervisor'
        );
    }
}

// ...

class Membership extends Model

{
    public function newCollection(array $models = [])

    {
        return new MembershipsCollection($models);    
    }
}

And consume it like:


$user->memberships->hasMembershipAt($organization);

Our code is starting to look a lot more expressive now, and improves the way we are able to communicate with non-developers about it. Non-technical folks can look at the above line of code and be able to understand it.

One place this really shines is in Authorization Policies. We are able to write expressive code to check if a user is allowed to do something based on their memberships.


class DepartmentPolicy
{
    public function update(User $user, Department $department)
    {
        return $user->memberships
            ->hasSupervisionMembershipAt($department->organization);
    }
}

It's beneficial to put methods such as hasMembershipAt and hasSupervisionMembershipAt within our custom collections class, in order to keep the user model a bit thinner. We wouldn't want to pollute it with tons of helper methods as the cognitive overhead and noise compete with the expression we gain.


Conclusion

Assess whether your N:N relationships could be better served by N:1 relations within your application. You may need to create other classes such as custom collections to group bits of logic pertaining to specific sets of model instances. However, that quite literally improves the testability of our application, the code is so well isolated in the collection that it is practically begging to be unit tested.

Ultimately, writing "expressive" code is not the easiest thing, but it's something that gets easier the more you do it. It's useful to model the objects in your application around domain concepts that we use with our colleagues and customers. It can even help inform the design of your codebase.

It's worth taking a look at how the Doctrine ORM models our data, it prevents us from being silly with these kinds of relationships, promoting the concept of domain aggregates in our models.

Published on 02 Aug 2024, by Kyle.