Relationship issues

Or in other words, dealing with complex database relations and Laravel models.

Recently I had to deal with a complex performance issue in one of our larger Laravel projects. Let me quickly set the scene.

We want an admin user to see an overview of all people in the system in a table, and we want a column in that table to list which contracts are active at that moment for each person.

The relation between Contract and Person is as follows:

Contract > HabitantContract > Habitant > Person

I don't want to spend too much time going into details as to how we came to this relationship hierarchy. It's important for you to know that, yes, this hierarchy is important for our use cases: a Contract can have several Habitants, which are linked via a pivot model HabitantContract; and each Habitant has a relation to one Person.

Since we're showing an overview of all people, we'd like to do something like this in our controller:

class PeopleController
{
    public function index() 
    {
        $people = PersonResource::collection(Person::paginate());

        return view('people.index', compact('people'));
    }
}

Let's make clear that this is an oversimplified example, though I hope you get the gist. Ideally, we'd want our resource class to look something like this:

/** @mixin \App\Domain\People\Models\Person */
class PersonResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'name' => $this->name,

            'active_contracts' => $this->activeContracts
                ->map(function (Contract $contract) {
                    return $contract->contract_number;
                })
                ->implode(', '),

            // …
        ];
    }
}

Notice especially the Person::activeContracts relation. How could we make this work?

A first thought might be by using a HasManyThrough relation, but remember that we're 4 levels deep in our relation hierarchy. Besides that, I find HasManyThrough to be very confusing.

We could query the contracts on the fly, one-by-one per person. The issue with that is that we're introducing an n+1 issue since there'll be an extra query per person. Imagine the performance impact if you're dealing with more than just a few models.

One last solution that came to mind was to load all people, all contracts, and map them together manually. In the end that's exactly what I ended up doing, though I did it in the cleanest possible way: using custom relations.

Let's dive in.

# Configuring the Person model

Since we want our $person->activeContracts to work exactly like any other relation, there's little work to be done here: let's add a relation method to our model, just like any other.

class Person extends Model
{
    public function activeContracts(): ActiveContractsRelation
    {
        return new ActiveContractsRelation($this);
    }
}

There's nothing more to do here. Of course we're only starting, since we haven't actually implemented ActiveContractsRelation!

# The custom relation class

Unfortunately there's no documentation on making your own relation classes. Luckily you don't need much to learn about them: some code-diving skills and a little bit of time gets you pretty far. Oh an IDE also helps.

Looking at the existing relation classes provided by Laravel, we learn that there's one base relation that rules them all: Illuminate\Database\Eloquent\Relations\Relation. Extending it means you need to implement some abstract methods.

class ActiveContractsRelation extends Relation
{
    /**
     * Set the base constraints on the relation query.
     *
     * @return void
     */
    public function addConstraints() { /* … */ }

    /**
     * Set the constraints for an eager load of the relation.
     *
     * @param array $models
     *
     * @return void
     */
    public function addEagerConstraints(array $models) { /* … */ }

    /**
     * Initialize the relation on a set of models.
     *
     * @param array $models
     * @param string $relation
     *
     * @return array
     */
    public function initRelation(array $models, $relation) { /* … */ }

    /**
     * Match the eagerly loaded results to their parents.
     *
     * @param array $models
     * @param \Illuminate\Database\Eloquent\Collection $results
     * @param string $relation
     *
     * @return array
     */
    public function match(array $models, Collection $results, $relation) { /* … */ }

    /**
     * Get the results of the relationship.
     *
     * @return mixed
     */
    public function getResults() { /* … */ }
}

The doc blocks get us on the way, though it's not always entirely clear what needs to happen. Again we're in luck, Laravel still has some existing relation classes where we can look to.

Let's go through building our custom relation class step by step. We'll start by overriding the constructor and adding some type hints to the existing properties. Just to make sure, the type system will prevent us from making stupid mistakes.

The abstract Relation constructor requires both specifically for an eloquent Builder class, as well as the parent model the relationship belongs to. The Builder is meant to be the base query object for our related model, Contract, in our case.

Since we're building a relation class specifically for our use case, there's no need to make the builder configurable. Here's what the constructor looks like:

class ActiveContractsRelation extends Relation
{
    /** @var \App\Domain\Contract\Models\Contract|Illuminate\Database\Eloquent\Builder */
    protected $query;

    /** @var \App\Domain\People\Models\Person */
    protected $parent;

    public function __construct(Person $parent)
    {
        parent::__construct(Contract::query(), $parent);
    }

    // …
}

Note that we type hint $query both with the Contract model as well as the Builder class. This allows IDEs to provide better autocompletion, such as custom scopes defined on the model class.

We've got our relation constructed: it will query Contract models, and use a Person model as its parent. Moving on to building our query.

This is where the addConstraints method come in. It will be used to configure the base query. It will set up our relation query specifically to our needs. This is the place where most business rules will be contained:

Here's what addConstraints looks like, for now:

class ActiveContractsRelation extends Relation
{
    // …

    public function addConstraints()
    {
        $this->query
            ->whereActive() // A query scope on our `Contract` model
            ->join(
                'contract_habitants', 
                'contract_habitants.contract_id', 
                '=', 
                'contracts.id'
            )
            ->join(
                'habitants', 
                'habitants.id', 
                '=', 
                'contract_habitants.habitant_id'
            );
    }
}

Now I do assume that you know how basic joins work. Though I will summarize what's happening here: we're building a query that will load all contracts and their habitants, via the contract_habitants pivot table, hence the two joins.

One other constraint is that we only want active contracts to show up; for this we can simply use an existing query scope provided by the Contract model.

With our base query in place, it's time to add the real magic: supporting eager loads. This is where the performance wins are: instead of doing one query per person to load its contracts, we're doing one query to load all contracts, and link these contracts to the correct people afterwards.

This is what addEagerConstraints, initRelation and match are used for. Let's look at them one by one.

First the addEagerConstraints method. This one allows us to modify the query to load in all contracts related to a set of people. Remember we only want two queries, and link the results together afterwards.

class ActiveContractsRelation extends Relation
{
    // …

    public function addEagerConstraints(array $people)
    {
        $this->query->whereIn(
            'habitants.contact_id', 
            collect($people)->pluck('id')
        );
    }
}

Since we joined the habitants table before, this method is fairly easy: we'll only load contracts that belong to the set of people provided.

Next the initRelation. Again this one is rather easy: its goal is to initialise the empty activeContract relationship on every Person model, so that it can be filled afterwards.

class ActiveContractsRelation extends Relation
{
    // …

    public function initRelation(array $people, $relation)
    {
        foreach ($people as $person) {
            $person->setRelation(
                $relation, 
                $this->related->newCollection()
            );
        }

        return $people;
    }
}

Note that the $this->related property is set by the parent Relation class and it's a clean model instance of our base query so in other words, an empty Contract model:

abstract class Relation
{
    public function __construct(Builder $query, Model $parent)
    {
        $this->related = $query->getModel();
    
        // …
    }
    
    // …
}

Finally we arrive at the core function that will solve our problem: linking all people and contracts together.

class ActiveContractsRelation extends Relation
{
    // …

    public function match(array $people, Collection $contracts, $relation)
    {
        if ($contracts->isEmpty()) {
            return $people;
        }

        foreach ($people as $person) {
            $person->setRelation(
                $relation, 
                $contracts->filter(function (Contract $contract) use ($person) {
                    return $contract->habitants->pluck('person_id')->contains($person->id);
                })
            );    
        }

        return $people;
    }
}

Let's walk through what's happening here: on the one hand we've got an array of parent models, the people; on the other hand we've got a collection of contracts, the result of the query executed by our relation class. The goal of the match function is to link them together.

How to do this? It's not that difficult: loop over all people, and search all contracts that belong to each one of them, based on the habitants linked to that contract.

Almost done? Well… there's one more issue. Since we're using the $contract->habitants relation, we need to make sure it is also eagerly loaded, otherwise we just moved the n+1 issue instead of solving it. So it's back to the addEagerConstraints method for a moment.

class ActiveContractsRelation extends Relation
{
    // …

    public function addEagerConstraints(array $people)
    {
        $this->query
            ->whereIn(
                'habitants.contact_id', 
                collect($people)->pluck('id')
            )
            ->with('habitants')
            ->select('contracts.*');
    }
}

We're adding the with call to eagerly load all habitants, but also note the specific select statement. We need to tell Laravel's query builder to only select the data from the contracts table, because otherwise the related habitant data will be merged on the Contract model, causing it to have the wrong ids and what not.

Finally we need to implement the getResults method, which simply executes the query:

class ActiveContractsRelation extends Relation
{
    // …

    public function getResults()
    {
        return $this->query->get();
    }
}

And that's it! Our custom relation can now be used like any other Laravel relation. It's an elegant solution to solving a complex problem the Laravel way.