vanilo/properties
Vanilo Properties adds flexible product attributes to Laravel e-commerce stores: define properties and values, attach them to products, and manage catalog attributes for display, filtering, and organization. Integrates with Vanilo Products.
vanilo/properties is the dynamic-attribute layer of the Vanilo e-commerce suite. It gives any Eloquent model (in practice: Product) a set of named Property records — Color, Material, Wattage, Country of Origin — each owning a controlled list of PropertyValue records, and attaches those values to models through a polymorphic pivot. It is a specification/filtering system, not a variant generator: it answers "what is this product like", not "which SKU is this".
Minimum steps:
composer require vanilo/properties (pin it to the same major as the rest of your Vanilo packages — this module versions in lockstep with the suite).properties, property_values, and model_property_values (polymorphic pivot with model_type, model_id, property_value_id).src/Contracts/ first — Property, PropertyValue — then src/Models/, then src/Traits/. The service provider is auto-discovered; there is no publishable config worth touching on day one.use Illuminate\Database\Eloquent\Model;
use Vanilo\Properties\Traits\HasPropertyValues; // verify exact name in src/Traits
class Product extends Model
{
use HasPropertyValues;
}
use Vanilo\Properties\Models\Property;
$color = Property::create([
'name' => 'Color',
'slug' => 'color',
'type' => 'select',
]);
$red = $color->values()->create(['value' => 'red', 'title' => 'Red']);
$blue = $color->values()->create(['value' => 'blue', 'title' => 'Blue']);
$product->propertyValues()->sync([$red->id, $blue->id]);
First real use case: a category listing page with a "Color" filter and a spec table on the product detail page. That covers create, attach, read, and query in one pass.
Treat properties as reference data, not user content. Seed them in migrations/seeders or manage them in the admin UI; the storefront only ever reads them. Keep slug stable — it is your lookup key in views and query strings, and renaming it breaks saved filters.
Assign values, not properties. The pivot points at property_value_id. A common early bug is passing a property_id into sync(). Wrap the assignment in a small service method so callers can express intent in terms of slugs:
public function setProperty(Product $product, string $propertySlug, array $valueSlugs): void
{
$property = Property::where('slug', $propertySlug)->firstOrFail();
$ids = $property->values()->whereIn('value', $valueSlugs)->pluck('id');
$product->propertyValues()->syncWithoutDetaching($ids->all());
}
Read with the property eager-loaded. The pivot gives you values; the label and type live on the parent Property, so two levels are needed:
$product->load('propertyValues.property');
$specs = $product->propertyValues
->groupBy(fn ($v) => $v->property->slug)
->map(fn ($group) => $group->pluck('title')->implode(', '));
Filtering. Use whereHas on the morph relation and constrain on the value column. For multi-select filters, group the orWhereHas clauses:
Product::query()
->when($colors, fn ($q) => $q->whereHas('propertyValues', fn ($q) =>
$q->whereIn('value', $colors)
))
->get();
For a filter sidebar, one query per property facet is usually cheaper and clearer than a single mega-join.
Render by type, validate generically. The type column is a UI hint consumed by the PropertyTypes registry (see src/PropertyTypes.php). Map each type to a Blade partial — select/multiselect render a dropdown from $property->values, text renders an input, number a numeric field. On submit, validate propertyValues.* as array of existing property_values.id; the package will not validate cardinality or type for you.
Custom types. Register your own type class in a service provider's boot() (before any form is rendered), then set properties.type to that key. Typical candidates: color (swatch UI), dimension (value + unit), range (min/max). Keep the class thin — label, input view, and a cast() if you want typed access.
Integration points. If you use vanilo/admin, the property CRUD and the product form's property tab come for free; match your custom types to the admin's expectations or hide them from the UI. If you build your own admin, the properties index is a plain resource controller — nothing exotic.
model_type. If you register a morph map (Relation::enforceMorphMap), do it before writing any property data, or existing rows will point at FQCNs that no longer resolve. This is the single most common cause of "values disappeared" after a refactor.Property cascades to its PropertyValue rows; whether the pivot rows are cleaned up depends on the FK definition in the published migration. Audit the migration and add cascadeOnDelete() to property_value_id if it's missing — otherwise you get orphaned pivot rows and broken eager loads.type is immutable in practice. Changing a property from select to number does not rewrite existing values. Treat the type as fixed after creation, or write a migration that normalizes the values.PropertyValue rows with value = 'red' on the same property. Add a unique index on (property_id, value) in your own migration and use firstOrCreate in seeders.with('propertyValues.property'). This is easy to miss because the first page with 12 products looks fine and the category page with 200 does not.model_property_values (model_type, model_id) (usually present) and property_values (property_id, value). Filtering by value across a large catalog becomes a sequential scan otherwise.tests/ is your best reference for edge-case behaviour. Read it before assuming a helper method exists; several common operations (sync-by-slug, typed casting, facet counts) are not provided and belong in your own application layer.Vanilo\Properties\Contracts\Property in a service provider. Avoid subclassing the Eloquent models directly — the suite assumes the concrete classes in a few places.How can I help you explore Laravel packages today?