Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

derekmd's avatar

Combining multiple HasMany relationships into one model Relation query builder

This thread is more an extension of https://laracasts.com/forum/?p=431-eloquent-relation-setup-two-foreign-keys-to-same-table as I have a messages table containing two foreign key sender_user_id and recipient_user_id that both reference the users table. My problem is what if I wish to create an Eloquent Relation on the model so I can take advantage of lazy/eager-loading and re-contextualizing the results? I will be adding a messages() method to the User model below.

class User extends \Illuminate\Database\Eloquent\Model
{
    public function messagesSent()
    {
        return $this->hasMany(Message::class, 'sender_user_id');
    }

    public function messagesReceived()
    {
        return $this->hasMany(Message::class, 'recipient_user_id');
    }
}

The solution I came up with was to extend \Illuminate\Database\Eloquent\Relations\HasMany to support multiple values in its $foreignKey parameter.

<?php

namespace App\Database\Eloquent\Relations;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * Class HasManyWithKeys.
 *
 * Extend Illuminate\Database\Eloquent\Relations\HasMany so you can reference
 * unify HasMany relationships on different foreign keys in the same table.
 *
 * @package App\Database\Eloquent\Relations
 */
class HasManyWithKeys extends HasMany
{
    /**
     * @var array
     */
    protected $foreignKeys;

    /**
     * Create a new has one or many relationship instance.
     *
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @param \Illuminate\Database\Eloquent\Model $parent
     * @param array $foreignKeys
     * @param string $localKey
     *
     * @return HasManyWithKeys
     */
    public function __construct(Builder $query, Model $parent, $foreignKeys, $localKey)
    {
        $this->foreignKeys = $foreignKeys;

        parent::__construct($query, $parent, $foreignKeys[0], $localKey);
    }

    /**
     * Set the base constraints on the relation query.
     */
    public function addConstraints()
    {
        if (static::$constraints) {
            $foreignKeys = $this->foreignKeys;

            $this->query->where(function ($query) use ($foreignKeys) {
                foreach ($foreignKeys as $foreignKey) {
                    $query->orWhere(function ($query) use ($foreignKey) {
                        $query->where($foreignKey, '=', $this->getParentKey())
                            ->whereNotNull($this->foreignKey);
                    });
                }
            });
        }
    }

    /**
     * Set the constraints for an eager load of the relation.
     *
     * @param array $models
     */
    public function addEagerConstraints(array $models)
    {
        $foreignKeys = $this->foreignKeys;

        $this->query->where(function ($query) use ($foreignKeys, $models) {
            foreach ($foreignKeys as $foreignKey) {
                $query->orWhere(function ($query) use ($foreignKey, $models) {
                    $$query->whereIn($foreignKey, $this->getKeys($models, $this->localKey));
                });
            }
        });
    }
}

Then in the above model definition I could add:

class User extends \Illuminate\Database\Eloquent\Model
{
    // ...

    public function messages()
    {
        return $this->hasManyWithKeys(Message::class, ['recipient_user_id', 'sender_user_id']);
    }

    /**
     * Obviously move this to a parent class or Trait.
     *
     * Define a one-to-many relationship on a table with multiple foreign keys
     * referencing the same model.
     *
     * @param string $related
     * @param array|string $foreignKeys
     * @param string $localKey
     *
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function hasManyWithKeys($related, $foreignKeys = null, $localKey = null)
    {
        // Default to Illuminate\Database\Eloquent\Relations\HasMany for less
        // than two foreign keys.
        if (empty($foreignKeys) || is_string($foreignKeys)) {
            return $this->hasMany($related, $foreignKeys, $localKey);
        }

        if (count($foreignKeys) === 1) {
            return $this->hasMany($related, $foreignKeys[0], $localKey);
        }

        $instance = new $related;

        $localKey = $localKey ?: $this->getKeyName();

        $foreignKeys = array_map(function ($foreignKey) use ($instance) {
            return $instance->getTable() . '.' . $foreignKey;
        }, $foreignKeys);

        return new HasManyWithKeys($instance->newQuery(), $this, $foreignKeys, $localKey);
    }
}

With this you can then use the query builder returned by $user->messages() to retrieve message threads or the unread count for that user.

So did I reinvent the wheel here? Reading through https://laravel.com/docs/5.2/eloquent-relationships and the source of namespace \Illuminate\Database\Eloquent\Relations\*, I was unable to find the capabilities I was looking for. (And yes, that $foreignKeys[0] constructor parameter is cringe-worthy.)

0 likes
9 replies
acasar's avatar

A simpler way would be to use union instead:

class User extends \Illuminate\Database\Eloquent\Model
{
    public function messagesSent()
    {
        return $this->hasMany(Message::class, 'sender_user_id');
    }

    public function messagesReceived()
    {
        return $this->hasMany(Message::class, 'recipient_user_id');
    }

    public function messages()
    {
        return $this->messagesSent()->union($this->messagesReceived()->toBase());
    }
}

And eager loading should still work just fine:

User::with('messages')->get();

Note: if you are using Laravel 5.1, you should use getQuery() instead of toBase(), which only works in 5.2.

4 likes
acasar's avatar

Hmm on a second thought eager loading will probably not work since where constraint will only be added to the original query... :)

1 like
adamwathan's avatar

Any reason to not just do:

<?php

class User extends \Illuminate\Database\Eloquent\Model
{
    public function messages()
    {
        return Message::where('sender_user_id', $this->id)->orWhere('recipient_user_id', $this->id);
    }

    public function getMessagesAttribute()
    {
        return $this->messages()->get();
    }
    
    public function messagesSent()
    {
        return $this->hasMany(Message::class, 'sender_user_id');
    }

    public function messagesReceived()
    {
        return $this->hasMany(Message::class, 'recipient_user_id');
    }
}

EDIT: Maybe doesn't work with eager loading...

1 like
derekmd's avatar

I didn't even think of an accessor method! For the sake of later chaining query conditions, messages() should likely be changed to a nested disjunctive:

public function messages()
{
    return Message::where(function ($query) {
        return $query->where('sender_user_id', $this->id)->orWhere('recipient_user_id', $this->id);
    });
}

However you're also correct about this relationship being unusable for with() or load(). Both of these methods require the relationship builder implementing Illuminate\Database\Eloquent\Relations\Relation. Obviously it's not too useful to eager load all of a user's messages but you can re-use the relationship for finer purposes. e.g., retrieve this user and the latest message from their 5 most recent conversation threads.

But anytime I'm extending vendor code (creating a dependency with that release) and passing ignored junk parameters to the parent class ($foreignKeys[0] to HasMany::__construct()), it gives an indication it's likely an over-engineered solution.

korridor's avatar

I created a package with a custom relationship that merges two hasMany relations. This package fully supports eager and lazy loading. I hope this helps somebody who has the same problem and needs eager loading.

Link to package: https://github.com/korridor/laravel-has-many-merged

A short example:

use Korridor\LaravelHasManyMerged\HasManyMergedRelation;

class User extends Model
{
    use HasManyMergedRelation;
    
    // ...

    /**
     * @return HasManyMerged|Message
     */
    public function messages()
    {
        return $this->hasManyMerged(Message::class, ['sender_user_id', 'receiver_user_id']);
    }

}
4 likes

Please or to participate in this conversation.