oliverbj
oliverbj

Reputation: 6062

Laravel / PHP - Advanced polymorphic relationship

In my web application, users can upload documents or emails to channels.

A channel can furthermore then have document_tags and email_tags, that all uploaded documents/emails automatically should inherit.

Furthermore, document_tags and email_tags will have different descriptions: tag_descriptions. So for example if we have a document, uploaded to a channel that have the tags: animals (id = 1) and pets (id = 2)

  1. Document #55 is upladed to Channel #8.
  2. Document #55 will automatically inherit the tags, that have document_tags.channel_id = 55 (this can be accessed with the following relationship: $channel->documenttags). In this case animals and pets.
  3. Now the user should be able to set an unique description for the tegs animals and pets in tag_descriptions, for example:

tag_descriptions

id | taggable_type   | taggable_id  | typeable_type | typeable_id | description
1  | App\DocumentTag | 1            |  App\Document | 55          | My unique description for animals. 
2  | App\DocumentTag | 2            |  App\Document | 55          | My unique description for pets.

Now in above database design the uploaded document #55, have the tags: animals and pets associated, but further these two tags have a unique description, that is unique for the specific document.

If I upload another document, or an email (let's say email #20), then I imagine it will look like:

tag_descriptions:

id | taggable_type   | taggable_id  | typeable_type | typeable_id | description
1  | App\DocumentTag | 1            |  App\Document | 55          | My unique description for animals. 
2  | App\DocumentTag | 2            |  App\Document | 55          | My unique description for pets.
3  | App\EmailTag    | 1            |  App\Email    | 20          | Another unique description for animals. 
4  | App\EmailTag    | 2            |  App\Email    | 20          | Yet another unique description for pets.

Now the email #20 also have the tags animals and pets, but in this case, the user can set unique descriptions for the tags.

Now my question is:

Is above design doable, and is it considered best practice in Laravel / PHP? I am a bit unsure how to structure the code, because the TagDescription model will suddenly have two polymorphic relationships (taggable and typeable), and I cannot find anything in the documentation that this is supported.

Furthermore, I am unsure if I can use the above design to access the unique descriptions through the specific uploaded document, such as:

//In my Document.php model:
public function tagdescriptions()
{
    return $this->morphMany(TagDescription::class, 'typeable');
}

Then use it like: $document->tagdescriptions.

Last but not least - I am a bit unsure how to save the unique tag description for the specific taggable_id / taggable_type and unique email/document. (typeable_id and typeable_type).

Upvotes: 4

Views: 2429

Answers (1)

Jed Lynch
Jed Lynch

Reputation: 2166

I'm not sure exactly what you are trying to do, but a table with two polymorphic relations doesn't make sense. The table that enables the polymorphic relation is a pivot table. While I get that you want unique descriptions for each tag and relationship type, a pivot table should only have two foreign key columns, one the table relating to and relating from.
There is another way, which is to use the polymorphic relationship as a constraint on the pivot table. To start with the pivot table in the polymorphic relationship should be renamed to "taggables". You do not need a "email_tag" table and "document_tag" table, you can use a table called "tags". To get a unique description for each tag, you can add the description to the table "tabblables".

The migration file looks like this....

public function up()
{
    Schema::create('taggables', function (Blueprint $table) {
        $table->increments('id');
        $table->unsignedInteger('tag_id');
        $table->unsignedInteger('taggable_id');
        $table->string('taggable_type');
        $table->string('description');
        $table->timestamps();
    });

    Schema::create('tags', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->timestamps();
    });

    Schema::create('documents', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->timestamps();
    });

    Schema::create('emails', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->timestamps();
    });
}

Here what you need to do in your email and document models.

class Email extends Model
{
    /**
     * Get all of the tags.
     */
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable')->withPivot('description');
    }
}

class Document extends Model
{
    /**
     * Get all of the tag descriptions.
     */
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable')->withPivot('description');
    }
}

The "withPivot" function will return the value of the column specified in the query.

Here is what you need to do in your Tags Model.

class Tag extends Model
{
    /**
     * Get all of the tag descriptions.
     */
    public function documents()
    {
        return $this->morphByMany(Document::class, 'taggable');
    }

    /**
     * Get all of the tag descriptions.
     */
    public function emails()
    {
        return $this->morphByMany(Email::class, 'taggable');
    }
}

You do not need a "Taggables" table model.

Here is what is going. When you tinker...

$email = App\Email::find(1)->tags;

This query will run...

select `tags`.*, 
    `taggables`.`taggable_id` as `pivot_taggable_id`, 
    `taggables`.`tag_id` as `pivot_tag_id`, 
    `taggables`.`taggable_type` as `pivot_taggable_type`, 
    `taggables`.`description` as `pivot_description` 
from `tags` 
inner join `taggables` on `tags`.`id` = `taggables`.`tag_id` 
where `taggables`.`taggable_id` = 1 
and `taggables`.`taggable_type` = 'App\Email'

What you see is the constraint of the polymorphic relationship can query the unique description. In summing up, you can't put more than two foreign key relations in a pivot table, but you can add the constraint to your pivot table.

I think it looks cleaner to add this your AppServiceProvider.php file...

public function boot()
{
    Relation::morphMap([
        'email'     => Email::class,
        'document'  => Document::class
    ]);
}

https://laravel.com/docs/5.8/eloquent-relationships#polymorphic-relationships

This will enable you to store a value of "email" or "document" as your taggable type.

For posting to the tables I think this looks the cleanest...

$tag = Tag::firstOrNew(["name"=>"#tag"]);
$tag->name = "#tag";

$document = new Document();
$document->name = "new doc";
$document->save();
$document->tags()->save($tag,["description"=>"some description"]);

You can certainly use attach(). Save() uses attach() as shown here... https://github.com/laravel/framework/blob/5.8/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php#L871

Hope this helps.

Upvotes: 3

Related Questions