user1727524
user1727524

Reputation: 13

Laravel ORM dynamic model attributes

I have a database set up that I am not sure how to code in laravel. I am trying to basically get dynamic attribute names from custom input.

Here's the DB setup:

Category:
-ID
-Name

Product:
-ID
-Category_id

Product_Attribute:
-ID
-Category_id
-Attribute_Name

Attribute_value:
-ID
-Product_id
-Product_attribute_id
-Value

There can be multiple values for each attribute and I don't have a set list of attributes as it can change depending on the category/product. Some products have some attributes and some don't. Some will have the same key/name as in other categories but will rarely overlap for my purposes but i can work around that if need be.

I there a way to setup laravel so i can look through the keys / values as well as call them by name

echo $product->$Attribute_Name;

or

echo $product->attributes[$Attribute_Name];

or something similar

but i also need to pull all products where attribute name = y and attribute Value = X

select * from Products join Attribute_value on products.ID = Attribute_value.Product_id join Product_Attribute on Category_id = Products.Category_id and Product_Attribute.ID = Attribute_value.Product_attribute_id where Product_Attribute = '{attribute_name}' and Attribute_value = '{Attribute_value}'

This is only return the products but not with the associated data or the other attributes. I can't find and easy way of loading that data without having to build a class to populate it. Ideally I would like to be able to change the values and save them using the ORM similar to how a one to many relationship works.

I have seen this type of structure before in databases. I was wondering if there was a way to do this easily in laravel without having to create a bunch of custom functions to load the attributes for each product.

Thanks

Upvotes: 0

Views: 2952

Answers (1)

Namoshek
Namoshek

Reputation: 6544

I'm a little confused by your question, but what you first want to do is create all the models and relationships. You don't necessarily need a model for each of the four tables, but I'd strongly recommend it.

class Category
{
    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

    public function productAttributes(): HasMany
    {
        return $this->hasMany(ProductAttribute::class);
    }
}

class Product
{
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function attributeValues(): HasMany
    {
        return $this->hasMany(AttributeValue::class);
    }
}

class ProductAttribute
{
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function attributeValues(): HasMany
    {
        return $this->hasMany(AttributeValue::class);
    }
}

class AttributeValue
{
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    public function productAttribute(): BelongsTo
    {
        return $this->belongsTo(ProductAttribute::class);
    }
}

This code expects you to consider the Laravel naming standards of tables and properties.

After defining the classes and relationships, you may load products with their attributes like this:

$products = Product::query()
    ->with('attributeValues.productAttribute')
    ->where('category_id', $categoryId)
    ->get();

Because this makes accessing an attribute by it's name a pain...

$product = $products->first();
$color   = optional($product->attributeValues
                            ->where('productAttribute.name', 'color')
                            ->first())->value ?? 'white';

... you can also override the __get($name) method to add a nice accessor for your attributes:

class Product
{
    public function __get(string $name)
    {
        if ($name === 'attrs') {
            return (object) $this->attributeValues->mapWithKeys(function ($attributeValue) {
                return [$attributeValue->productAttribute->name => $attributeValue->value];
            });
        }

        return parent::__get($name);
    }
}

After doing so, you should be able to access your attributes like this:

$product = $products->first();
$color   = $product->attrs->color;

// or if you need to retrieve an attribute by name stored in a variable
$name = 'color';
$attr = $product->attrs->$name;

Of course you can also omit the (object) cast in the __get($name) accessor to return an array instead. You then receive this syntax: $product->attrs['color']. Either way, this will return an error if a property is not set / not in the array. Make sure to catch this. You may also want to add some caching to avoid building the attrs object/array over and over again.

Please note: The $attributes property is used by the Eloquent base model internally to store all the properties of a model. So this name is reserved and you should use something else like attrs instead.


Edit: Two more options for getters would be the following ones:

class Product
{
    public function getColorAttribute(string $default = 'white'): string
    {
        return optional($this->attributeValues
                             ->where('productAttribute.name', 'color')
                             ->first())->value ?? $default;
    }

    public function getAttr(string $name, $default = null)
    {
        return optional($this->attributeValues
                             ->where('productAttribute.name', $name)
                             ->first())->value ?? $default;
    }
}

Similarly, you could design a setter:

class Product
{
    public function setAttr(string $name, $value): void
    {
        if ($this->hasAttr($name)) {
            $attr = $this->attributeValues
                         ->where('productAttribute.name', $name)
                         ->first();
            $attr->value = $value;
            $attr->save();
        } else {
            throw new \Exception(sprintf('This product may not have the attribute [%s].', $name));
        }
    }

    public function hasAttr(string $name): bool
    {
        return $this->attributeValues
                    ->contains('productAttribute.name', $name);
    }
}

Upvotes: 1

Related Questions