sunel/eav
Sunel/EAV brings flexible Entity-Attribute-Value modeling to Laravel. Attach dynamic attributes to Eloquent models without constant migrations, store typed values, and query entities by custom attributes—ideal for catalogs, CMS data, and configurable products.
Install and publish (5 minutes):
composer require sunel/eav
php artisan vendor:publish --provider="Sunel\Eav\EavServiceProvider"
php artisan migrate
Read three things before writing a line of code, in this order:
config/eav.php — table names and model bindings. This is your override surface, not just configuration.src/Models/ — Entity, Attribute, Value, Option. These are ordinary Eloquent models; anything you know about Eloquent applies directly.src/Traits/ — the trait you apply to your own model. This is where the __get/__set/save hooks live if the package provides magic attribute access.First use case — a product catalog with merchandiser-defined attributes:
// 1. Define the attribute once (seeder, admin screen, or migration)
$color = Attribute::create([
'code' => 'color',
'name' => 'Color',
'type' => 'string', // string|text|int|decimal|boolean|date|select
'options' => ['red', 'green', 'blue'],
]);
// 2. Apply the trait to your model
use Sunel\Eav\EavTrait;
class Product extends Model
{
use EavTrait;
}
// 3. Read/write through the model
$product->color = 'red';
$product->save();
$product->color; // 'red', resolved from eav_values
$product->eavValues; // raw Value models, if the relation is exposed
Confirm the exact trait namespace and helper method names against src/ — the README is from 2018 and forks differ. The Value model is always the escape hatch when magic accessors don't fit:
Value::create([
'entity_id' => $product->id,
'attribute_id' => $color->id,
'value' => 'red',
]);
Treat attribute definitions as seed data, not runtime config. Put them in a seeder or a data migration so dev, staging, and production match. The package will happily let you create attributes in a controller; don't.
One attribute set per model type. If the package exposes entity types/sets, bind Product to a single set and attach attributes to that set. This is what keeps Attribute::where('code', 'color') unambiguous when several models need a "size" attribute.
Eager load values, always.
$products = Product::with('eavValues.attribute')->paginate(50);
Lazy access on a 50-row listing with 10 attributes is 500+ queries. This is the single biggest day-to-day performance decision.
Querying by attribute. For a single filter, whereHas is fine:
$colorId = Attribute::where('code', 'color')->value('id');
Product::whereHas('eavValues', function ($q) use ($colorId) {
$q->where('attribute_id', $colorId)->where('value', 'red');
})->get();
For multi-attribute filters or sorting, join eav_values once per attribute, or — better — maintain a flat projection table (product_flat) rebuilt on save. EAV is a storage model, not a reporting model.
Serialization for APIs. Append a code→value map so your JSON doesn't expose the normalized shape:
protected $appends = ['attributes'];
public function getAttributesAttribute()
{
return $this->eavValues->mapWithKeys(
fn ($v) => [$v->attribute->code => $v->value]
);
}
Validation from metadata. The attribute rows are the schema; generate rules from them instead of hand-writing:
$rules = $attributes->mapWithKeys(function ($a) {
return ["eav.{$a->code}" => [
$a->required ? 'required' : 'nullable',
match ($a->type) {
'int' => 'integer',
'decimal' => 'numeric',
'boolean' => 'boolean',
'date' => 'date',
'select' => Rule::in($a->options),
default => 'string',
},
]];
});
Write in a transaction. One entity plus N values is multi-row; a half-saved product is a real failure mode.
Cache attribute definitions. They change rarely and are read on every request that renders a form. Cache the collection keyed by entity type and bust it in the Attribute model's saved/deleted events.
Testing. Seed a small attribute fixture in setUp(), use RefreshDatabase, and assert at the storage layer (assertDatabaseHas('eav_values', [...])) as well as through the model — the magic accessors can mask write failures.
str_* → Str::*), $dates, service provider defer behavior, and getAttribute signatures. Budget for a fork in your vendor namespace rather than fighting --ignore-platform-reqs in production.getAttribute collision. If the trait overrides Eloquent's getAttribute, real DB columns still win, but an EAV attribute named status can shadow a model accessor or relation of the same name. Namespace codes (spec_status) or keep a reserved-name list and validate against it when attributes are created.boolean false comes back as "0" or "" depending on the write path; decimals drop trailing zeros. Cast on read using a per-attribute cast map derived from type — never trust the raw value column in business logic.(entity_id, attribute_id) for single-valued attributes; multi-valued attributes need a different design (a separate repeatable group or a JSON column) — don't try to bolt it on.onDelete('cascade') on entity_id and attribute_id. If they aren't there, add them in your own migration; deleting an attribute otherwise leaves rows that silently break filters.(entity_id, attribute_id) and (attribute_id, value). Without the second one, every attribute filter is a full scan.save(). The pattern $product->save(); $product->color = 'red'; losing the value is a classic. Wrap writes in a helper that always saves.DB::listen() or Telescope, then inspect eav_values for the entity id directly. toArray() will not include EAV values unless you append them, which makes "it works in the model but not the API" a common confusion.config/eav.php let you subclass Attribute/Value (add casts, scopes, custom types) without forking. That's the cleanest place to add behavior the package doesn't ship.json column plus generated columns beats EAV on simplicity, query cost, and validation. Reach for this package when the attribute list is genuinely open-ended and admin-defined.How can I help you explore Laravel packages today?