Strawberry_Sacha's avatar

Two-way Many to Many Polymorphic Relationships

I think that's probably the best way to describe what I'm trying to achieve.

Say I have 4 models:

Video, Podcast, Article, and Tool

I want all of these models to be able to be related to each other.

So a video can be related to a podcast, an article can be related to a tool and a video, a tool can be related to a podcast and a video... and so on.

The first thing I reached for was a many-to-many polymorphic relationship, but that isn't quite right because there isn't a single intermediary (for example, tags) that multiple models share.

My ideal table structure would be something like this:

videos:
    - id
    - title

podcasts:
    - id
    - title

articles:
    - id
    - title

tools:
    - id
    - title

relatables:
    - model_id
    - model_type
    - relatable_id
    - relatable_type

Hopefully I'm just overcomplicating something that's otherwise quite simple. I just don't want to end up with multiple relationship tables, if I can help it.

Does anybody have any way that I can achieve this two-way relationship?

0 likes
4 replies
staudenmeir's avatar

You can build the relationship yourself:

class Video extends Model
{
    public function podcasts()
    {
        return $this->morphToMany(Podcast::class, 'relatable', null, null, 'model_id')
            ->where('model_type', Podcast::class);
    }
}
nacho-villanueva's avatar

Don't take my word, I'm not sure of my solution, but I was able to build a custom relation which extends MorphToMany. I also feel like I am overcomplicating this, but I would like to get some other opinion. Here is the implementation, (I've only overwrote the attach cause is the only one I needed):

<?php

namespace App\Models\Relations;

use Illuminate\Database\Eloquent\Relations\MorphToMany;

class MultiMorphToMany extends MorphToMany
{
    /**
     * Attach models to the parent with custom attributes.
     *
     * @param  mixed  $ids
     * @param  array  $attributes
     * @param  bool  $touch
     * @return void
     */
    public function attach($id, array $attributes = [], $touch = true)
    {
        // Automatically set the resource_type attribute
        $attributes['resource_type'] = $this->parent->getMorphClass();

        parent::attach($id, $attributes, $touch);
    }
}
<?php

namespace App\Models\Traits;

use App\Models\Relations\MultiMorphToMany;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Str;

trait HasMultiMorphToMany
{
    /**
     * Define a custom morphedByMany relationship.
     *
     * @param  string  $related
     * @param  string  $parent The parent model class. Example: 'resource' for InvestorUpdate
     * @param  string  $name The name of the relationship. Example: 'accessors' for InvestorUpdate
     * @param  string  $table The name of the pivot table. Example: 'accesses'
     * @param  string  $foreignPivotKey
     * @param  string  $relatedPivotKey
     * @param  string  $parentPivotType The name of the column that holds the parent model's morph class. Example: 'resource_type' for InvestorUpdate
     * @param  string  $parentKey
     * @param  string  $relatedKey
     * @return MultiMorphToMany
     */
    public function multiMorphToMany($related, $parent, $name, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentPivotType = null, $parentKey = null, $relatedKey = null, $relation = null)
    {
        $relation = $relation ?: $this->guessBelongsToManyRelation();

        // First, we will need to determine the foreign key and "other key" for the
        // relationship. Once we have determined the keys we will make the query
        // instances, as well as the relationship instances we need for these.
        $instance = $this->newRelatedInstance($related);

        $foreignPivotKey = $foreignPivotKey ?: $name.'_id';

        $relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey();

        $parentPivotType = $parentPivotType ?: $parent . '_type';

        // Now we're ready to create a new query builder for the related model and
        // the relationship instances for this relation. This relation will set
        // appropriate query constraints then entirely manage the hydrations.
        if (! $table) {
            $words = preg_split('/(_)/u', $name, -1, PREG_SPLIT_DELIM_CAPTURE);

            $lastWord = array_pop($words);

            $table = implode('', $words).Str::plural($lastWord);
        }

        /** @var Builder $query */
        $query = $instance->newQuery();
        $query->where($table . '.' . $parentPivotType, $this->getMorphClass());

        return $this->newMultiMorphToMany(
            $query, $this, $name, $table,
            $foreignPivotKey, $relatedPivotKey, $parentKey ?: $this->getKeyName(),
            $relatedKey ?: $instance->getKeyName(), $relation, true
        );
    }

    /**
     * Instantiate a new CustomMorphToMany relationship.
     *
     * @param  Builder  $query
     * @param  Model  $parent
     * @param  string  $name
     * @param  string  $table
     * @param  string  $foreignPivotKey
     * @param  string  $relatedPivotKey
     * @param  string  $parentKey
     * @param  string  $relatedKey
     * @param  string|null  $relationName
     * @param  bool  $inverse
     * @return MultiMorphToMany
     */
    protected function newMultiMorphToMany(Builder $query, Model $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName, $inverse): MultiMorphToMany
    {
        return new MultiMorphToMany(
            $query, $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName, $inverse
        );
    }
}

Example usage:

public function podcasts(): MultiMorphToMany
    {
        return $this->multiMorphToMany(
            Podcast::class,
			'model',
            'relatable',
            'relatables'
        );
    }

Please or to participate in this conversation.