Search code examples
phplaravelpolymorphismrelation

How to have multiple same polymorphic relations in one model in PHP-Laravel?


I'm dealing with a challenge where I want to create a polymorphic model Localization. This will include long-text columns like default, en_us, de_de,...

Now imagine another model Product. Products usually have text attributes like name, description,... Here comes the part, where I could use a polymorphic relation with Localizations. The localizations table is supposed to have this type of structure:

id localizable_id localizable_type default en_us de_de
1 25 App\Model\Product Phone null Telefon
2 25 App\Model\Product The best phone on the market without an audio jack null Das beste Telefon auf dem Markt ohne Audioanschluss
3 15 App\Model\Article Top 10 products null Top 10 Produkte
4 15 App\Model\Job Salesman null Verkäufer

If I want a Product to have name and description localizable, then according to the Laravel documentation, you should expect something like this in my code:

class Product extends Model
{
  public function name() // Expecting model with default attribute 'Phone'
  {
    return $this->morphOne(Localization::class,'localizable');
  }

  public function description() // Expecting model with default attribute 'The best phone on the market without an audio jack'
  {
    return $this->morphOne(Localization::class,'localizable');
  }
}

No matter how obvious it is, it won't work correctly, because I can't expect two identical methods to return different values.

On the other side, if I wanted to follow Laravel's convention, the Localization model is supposed to look like this:

class Localizable extends Model
{
  public function localizable()
  {
    return $this->morphTo(__FUNCTION__,'localizable_type','localizable_id');
  }
}

As you can see in the example above, the polymorphic relation is inverse to what I need. The problem is that I want one table with all the strings, which might be translated (localizables) and use them in many other models than just the Product, like Shop, Job, Article,...

I need to somehow implement the distinction not only between localizable_types but also on which column/attribute it is supposed to be related. What is the best approach to achieve this?


Solution

  • I finally found a workaround that fulfills my expectations. Even though it is not the most beautiful (or Laravel) way to do it. I got inspired from a similar struggle that I've found on GitHub here.

    The point of this solution is to override Product's getMorphClass() method that is being used to determine the *_type column value. My Product model:

    class Product extends Model
    {
      protected $morphClass = null; // Create an attribute that will not be saved into the DB
      public function getMorphClass() // Method for determinating the type value
      {
        return $this->morphClass? : self::class;
      }
    
      public function name()
      {
        $this->morphClass = self::class . '.name'; // App\Models\Product.name
        return $this->morphOne(Localization::class, 'localizable');
      }
    
      public function description()
      {
        $this->morphClass = self::class . '.description'; // App\Models\Product.description
        return $this->morphOne(Localization::class, 'description');
      }
    
    }
    

    The modification above will affect, how Laravel is going to save the Localizations to the database. Now, some work needs to be done from the other side of the relations - custom polymorphic types. Update your AppServiceProvider class in the app/Providers/AppServiceProvider.php file. You have to add morphMap to the boot() method:

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
      // ...any of your previous modifications...
    
      Relation::morphMap([
        Product::class . '.name' => Product::class,
        Product::class . '.description' => Product::class,
      ]);
    }
    

    BUT BE AWARE: Once you decide to use multiple same-type morphs in your model, you have to change the protected attribute $morphClass in every dynamic method before calling morphOne() or morphMany(). Otherwise you might be saving descriptions to names or vice versa.

    Also, when you will use the morphTo() method on the other side, you will only get the Product - if you need to know, what attribute on the Product model is being related on, I recommend you to create some method for extracting the context.

    Please feel free to comment any potential threats in this approach.