Laravel 8: One To Many Polymorphic: Relate same child to different parents

1k Views Asked by At

I used the one-to-many polymorphic relationship like described in the laravel documentation, to be able to relate different parent models to one child model. i have assumed, that i would be able to assign different parent models to the same child model. but this does not work. as soon as i create a new relation with another parent model to the same child, the old relation is replaced.

Example:

A, B and C are parent models, each with one data-record (id=1).

X is the child model with one data-record (id=1)

I can't do something like that with the common methods:

A(id=1) <-> X(id=1)

B(id=1) <-> X(id=1)

C(id=1) <-> X(id=1)

Since the last creation of a relation always overwrites the previous one. In this example one relation would remain (C(id=1) <-> X(id=1))

I am able to do that with a many-to-many polymorphic implementation - but this is actually not what i want, since i do not want the parent models to be able to have more than one relation to the child model. (altough i could rule that out by creating a composite key within the *able table on the corresponding fields)

This is the actual code, that should assign one image to multiple parent models (but only the last save inside the loop remains - if i add a break at the end of the loop, the first one is saved):

public function store(StoreImageRequest $request)
{
    $validated = $request->validated();

    $image = $validated['image'];
    $name = isset($validated['clientName']) ? $image->getClientOriginalName() : $validated['name'];
    $fileFormat = FileFormat::where('mimetype','=',$image->getClientMimeType())->first();

    $path = $image->store('images');

    $imageModel = Image::make(['name' => $name, 'path' => $path])->fileFormat()->associate($fileFormat);
    $imageModel->save();

    $relatedModels = Image::getRelatedModels();

    foreach($relatedModels as $fqcn => $cn) {
        if(isset($validated['model'.$cn])) {
            $id = $validated['model'.$cn];
            $models[$fqcn] = call_user_func([$fqcn, 'find'], [$id])->first();
            $models[$fqcn]->images()->save($imageModel);
        }
    }
}
1

There are 1 best solutions below

3
On

Well, it's a bit more clear what you're trying to do now. The thing is, even though you want to enforce that a maximum of one of each parent is attached to the child, you're still actually trying to create a ManyToMany PolyMorphic relationship. Parents can have mutiple images and images can have multiple parents (one of each).

Without knowing the data structure, it could be viable, if the parents all have similar structures to consolidate them into one table, and the look into the relation HasOne ofMany to enforce that an image only have one of each parent.

If you insist on a Polymorphic relationship, I would do the following

    // child
    Schema::create('images', function (Blueprint $table) {
        $table->id();
        $table->timestamps();
    });

    // parents
    Schema::create('parent_ones', function (Blueprint $table) {
        $table->id();
        $table->timestamps();
    });


    Schema::create('parent_twos', function (Blueprint $table) {
        $table->id();
        $table->timestamps();
    });

    Schema::create('parent_trees', function (Blueprint $table) {
        $table->id();
        $table->timestamps();
    });

    // morph table
    Schema::create('parentables', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('image_id');
        $table->foreign('image_id')->references('id')->on('images');
        $table->string('parentable_type');
        $table->unsignedBigInteger('parentable_id');
        $table->timestamps();
        $table->unique(['image_id', 'parentable_type', 'parentable_id'], 'uniq_parents');
    });

The unique constraint on the table enforces that an image can only have one of each parent type.

Model relations

   // Child

   public function parent_one(): MorphToMany
    {
        return $this->morphedByMany(ParentOne::class, 'parentable');
    }

    public function parent_two(): MorphToMany
    {
        return $this->morphedByMany(ParentTwo::class, 'parentable');
    }

    public function parent_tree(): MorphToMany
    {
        return $this->morphedByMany(ParentTree::class, 'parentable');
    }


    // parents
    public function images(): MorphToMany
    {
        return $this->morphToMany(Image::class, 'parentable')->withTimestamps();
    }

Then it's up to you code to handle that it does not try to attach a parent to an image if it already has a parent of that type.

This could either be done in your controller or if the parent isn't replaceable, you could add it as validation in you Request.

A solution could be installing this package (untested by me). MorphToOne


Old answer

It sounds to me that you have flipped the One To Many relation in the wrong direction.

Your child model should implement morphTo and your parent models should implement morphMany

a 
    id - integer
    title - string
    body - text

b
    id - integer
    title - string
    url - string

c
    id - integer
    title - string
    url - string

x
    id - integer
    body - text
    commentable_id - integer
    commentable_type - string

Child Model

public class X extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

Parent Models

public class A extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(X::class, 'commentable');
    }
}

Adding to the relation:

$a = A::firstOrFail();
$x = X::firstOrFail();

// Attaches
$a->comments()->attach($x);

// Sync, removes the existing attached comments
$a->comments()->sync([$x]);

// Sync, but do not remove  existing
$a->comments()->sync([$x], false);
$a->comments()->syncWithoutDetaching([$x]);