Would you like fry with that? Using a HasOne over a HasMany relationship in Laravel

Blog post
by Tim MacDonald on the

When you are working with a one-to-many relationship, it is sometimes the case that a particular instance on the "many" side of the relationship is flagged as unique and important to your system in some way. It can be really handy to be able to access that unique instance in a first class way from your models. This post is going to cover how you can do that without introducing any new concepts into your application.

The idea of this unique relation over a HasMany relationship can be visualised as shown below in the example of a user having many payment methods (e.g., multiple credit cards), however only one payment method can ever be the "default" payment method at any given time.

Visual representation showing one user on the left and many payment methods on the right - which only one connection between a user and a payment method highlighted

For our example, we are have a "state" column on the payment method table, which can hold a few different values such as "available", "default", "expired", and "disabled".

Schema::create('payment_methods', function (Blueprint $table): void {
    // ...

    $table->foreignId('user_id')->constrained();
    $table->string('state');
}

and here is what our supporting models look like...

class User extends Model
{
    public function paymentMethods(): HasMany
    {
        return $this->hasMany(PaymentMethod::class);
    }
}

class PaymentMethod extends Model
{
    public function scopeWhereDefault(Builder $builder): void
    {
        $builder->where('state', '=', 'default');
    }
}

The inline way

Our first attempt to access the "default" payment method for a user might look something like the following...

$defaultPaymentMethod = $user->paymentMethods()
    ->whereDefault()
    ->first();

Although this approach works perfectly fine in isolation, it doesn't handle a lot of things that we are going to want for our app as it grows bigger. We have the following issues with regard to accessing the "default" relation:

  • No relation caching (unless we load all the payment methods into memory)
  • No way to eager load (without redefining the constraint)
  • Not reusable

The custom method way

To solve the re-usability issue, we could try wrapping this up into a custom method on the model...

class User extends Model
{
    public function paymentMethods(): HasMany
    {
        return $this->hasMany(PaymentMethod::class);
    }

    public function defaultPaymentMethod(): ?PaymentMethod
    {
        return $user->paymentMethods()
            ->whereDefault()
            ->first();
    }
}

$user->defaultPaymentMethod();

Although this has solved the re-usability issue, some of the previous issues still linger and we have introduced some new issues:

  • No query exposed for the relationship.
  • Unconventional method access to the relationship.
  • No relation caching (unless we load all the payment methods into memory)
  • No way to eager load (without redefining the constraint)
  • Not reusable

The accessor and query method way

In an attempt to address some more of these existing issues we might look at introducing a dedicated method to wrap up the query and an accessor to retrieve the first() item of the returned collection.

class User extends Model
{
    public function paymentMethods(): HasMany
    {
        return $this->hasMany(PaymentMethod::class);
    }

    public function defaultPaymentMethods(): HasMany
    {
        return $user->paymentMethods()
            ->whereDefault();
    }

    public function getDefaultPaymentMethodAttribute(): ?PaymentMethod
    {
        return $user->defaultPaymentMethods->first();
    }
}

$user->defaultPaymentMethod;

Again, we have dealt with some of the issues, but introduced some others:

  • Implicit exposure of a $user->defaultPaymentMethods relationship attribute
  • Singular and plural naming of defaultPaymentMethods and defaultPaymentMethod
  • Is kind of just confusing
  • No query exposed for the relationship.
  • Unconventional method access to the relationship.
  • No relation caching (unless we load all the payment methods into memory)
  • No way to eager load (without redefining the constraint)
  • Not reusable

The relation that always returns a collection way

Alternatively we could settle on exposing the defaultPaymentMethods relation that always returns a collection, but that is not great in my opinion as you are always going to be repeating the following...

$user->defaultPaymentMethods->first();

Why it's all a hack

This all just feels like hacky workaround, which I'd rather not do. I want something first class that represents this relation, but isn't confusing.

If we take a step back and look at all our solutions, we will see that the cause of all our problems is the HasMany relation, and the fact that we are using it when we only want one instance returned. The HasMany relation always returns a collection, so at some point we need to intercept it and tell it to return the first() result.

Diving the relations

The bit that is making this happen is the HasMany::getResults method.

public function getResults()
{
    return ! is_null($this->getParentKey())
        ? $this->query->get()
        : $this->related->newCollection();
}

This method is always going to return a collection - however looking at the HasOne::getResults method we can see that it is calling first() on the query for us (I've removed some of the code that we don't care about for the purpose of this post).

public function getResults()
{
    // ...

    return $this->query->first() ?: $this->getDefaultFor($this->parent);
}

This got me to thinking...could I not use a HasOne to model this relationship? The user does, after all, "have one default payment method". Drum roll, please 🥁

The HasOne way 🎉

If we attempt to model this HasOne over the HasMany relation, we adjust our model to the following...

class User extends Model
{
    public function paymentMethods(): HasMany
    {
        return $this->hasMany(PaymentMethod::class);
    }

    public function defaultPaymentMethod(): ?HasOne
    {
        return $this->hasOne(PaymentMethod::class)
            ->whereDefault();
    }
}

$user->defaultPaymentMethod;

The intentions here are clear as we are sticking to Laravel's conventions and anyone who knows those conventions will also know what to expect from the above relations, for example accessing the relation attribute will cache the instance. But lets take a look at our full problem list and see how we are going...

  • Implicit exposure of a $user->defaultPaymentMethods relationship attribute
  • Singular and plural naming of defaultPaymentMethods and defaultPaymentMethod
  • Is kinda just confusing
  • No query exposed for the relationship.
  • Unconventional method access to the relationship.
  • No relation caching (unless we load all the payment methods into memory)
  • No way to eager load (without redefining the constraint)
  • Not reusable

Hey, would you look at that! We have addressed all the issues with the previous approaches and the solution is minimal. What's more is we haven't had to introduce any workarounds or new concepts - we are just using the existing HasOne relationship Laravel provides out of the box.

We get eager loading...

$users = User::query()
   ->with(['defaultPaymentMethod'])
   ->etc();

We get relationship caching...

$user->defaultPaymentMethod; // hits the DB the first time
$user->defaultPaymentMethod; // doesn't hit the DB

and everything else you'd expect from a normal relationship.

Do not rely on ordering

The example we have used focused on the premise that there is some kind of unique flag per user that indicates which payment method we want to retrieve. Having some kind of unique flag per relation is essential to this approach. If you try to rely on ordering you are going to hit some issues. Instead of me outlining them here, I highly recommend you go and read Dynamic relationships in Laravel using subqueries by Jonathan Reinink instead, which is excellent and explains how to handle this kind of relation that relies on ordering (it's pretty darn magical to be honest).

The idea here is that user genuinly only has one default payment method, which is represented in the database via state. It doesn't rely on ordering and can be described with filtering via where statements. A limtus test to use to for this approach is as follows...

If the following...

$user->paymentMethods()->whereDefault()->count()

could return anything other than 1 or 0, it is not suitable for this approach.