msgphp/eav-bundle
A Symfony bundle for entity-attribute-value (EAV) modeling, letting you attach flexible, dynamic attributes and typed values to entities without schema changes. Integrates with Doctrine and the msgphp domain layer for extensible, metadata-driven data storage.
The package is a Symfony bundle (MsgPhp\EavBundle\MsgPhpEavBundle), not a Laravel package. It ships the EAV domain (Attribute, AttributeValue, AttributeMetadata) plus Doctrine XML mappings and a DI extension. In a Laravel app you have two realistic entry points:
msgphp/eav only (the plain domain library) and wire your own Doctrine mappings / repositories — this is the path of least resistance under Laravel.DependencyInjection/Configuration.php and Resources/config/*.xml and re-declaring the services in a ServiceProvider.If you are on Symfony, it's a normal three-line install:
composer require msgphp/eav-bundle
# config/bundles.php
MsgPhp\EavBundle\MsgPhpEavBundle::class => ['all' => true],
src/ of msgphp/eav — Attribute, AttributeValue, AttributeMetadata (short classes, read them end to end before writing anything).msgphp/eav-bundle/Resources/config/doctrine/*.orm.xml — the mapped-superclass definitions. This is the contract you inherit; if a column/association surprises you, it's defined here.msgphp/eav-bundle/DependencyInjection/Configuration.php — the class: map that lets you swap in your own entity classes.msgphp/domain — DomainId, DomainIdInterface, the Identifiable/Entity base. The IDs (AttributeId, AttributeValueId) are UUID-backed by default and rely on DBAL types registered by msgphp/domain-bundle.The canonical one: a Product catalogue where each product needs arbitrary, admin-defined properties (colour, weight, screen size, "is BPA free"). You don't want a migration per property.
// one-off, in a seeder or an admin controller
$attribute = new Attribute(AttributeId::create(), 'screen_size');
$attribute->setMetadata(new AttributeMetadata('float', multiple: false));
$em->persist($attribute);
$em->flush();
// later, per product
$product->addAttribute(new AttributeValue(
AttributeValueId::create(),
$attribute->getId(),
'6.1'
));
Two things to do before that first persist: (a) create your own App\Entity\Attribute extends MsgPhp\Eav\Attribute and register it under msgphp_eav.class.attribute (or your own service config), because the library classes are mapped-superclass and Doctrine will not manage them directly; (b) run doctrine:schema:validate and diff a migration.
Every msgphp bundle uses the same pattern: the library defines the domain entity, the bundle defines the mapping, you define the concrete class. Your entity must be the one Doctrine manages.
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use MsgPhp\Eav\Attribute as BaseAttribute;
/**
* @ORM\Entity(repositoryClass="App\Repository\AttributeRepository")
* @ORM\Table(name="eav_attribute")
* @ORM\Index(columns={"name"})
*/
class Attribute extends BaseAttribute
{
}
/**
* @ORM\Entity()
* @ORM\Table(name="eav_attribute_value")
* @ORM\Index(columns={"attribute_id"})
*/
class AttributeValue extends \MsgPhp\Eav\AttributeValue
{
}
Then point the bundle at them:
# config/packages/msgphp_eav.yaml
msgphp_eav:
class:
attribute: App\Entity\Attribute
attribute_value: App\Entity\AttributeValue
Under Laravel, reproduce the same mapped-superclass XML by hand or convert it to attributes on your subclass (a subclass can't redefine the parent's fields, so you generally copy the mapping onto your class and skip the XML entirely).
Give your Product the EAV side of the relation. The library exposes a HasAttributes-style interface/trait for exactly this — check src/ for the exact name in your version, since it moved between releases — and implement it on the aggregate:
class Product implements HasAttributes
{
/** @ORM\OneToMany(targetEntity=AttributeValue::class, mappedBy="product", cascade={"persist"}, orphanRemoval=true) */
private $attributes;
public function addAttribute(AttributeValueInterface $value): void
{
$this->attributes->add($value);
}
public function getAttribute(string $name): ?AttributeValueInterface
{
foreach ($this->attributes as $value) {
if ($value->getAttribute()->getName() === $name) {
return $value;
}
}
return null;
}
}
Keep the "get by name" helper on your entity, not in a repository — you'll call it in Blade templates constantly.
AttributeValue::$value is stored as a string. Cast at the boundary so the rest of the app never sees strings:
final class ProductAttributeReader
{
public function __construct(private AttributeRepository $attributes) {}
public function float(Product $product, string $name): ?float
{
$value = $product->getAttribute($name);
return $value?->getValue() === null ? null : (float) $value->getValue();
}
}
Drive the cast from AttributeMetadata::$dataType (or whatever your version calls it) rather than from per-call (float) casts, so admin-defined types actually mean something.
EAV means joins. Use fetch-join on the values you actually need, and filter by attribute.name in DQL rather than in PHP:
$qb->select('p', 'v', 'a')
->leftJoin('p.attributes', 'v')
->leftJoin('v.attribute', 'a', 'WITH', 'a.name IN (:names)')
->setParameter('names', ['colour', 'screen_size']);
A Product grid showing 20 products × 6 attributes will otherwise be 120 queries.
Three recurring flows worth building once:
AttributeMetadata, mark deprecated rather than deleting (deleting an Attribute orphans values).Attribute rows on the fly, then attach values.dataType before persist. The library does not validate value/type consistency for you.config/eav.php. MsgPhpEavBundle extends Symfony's Bundle and will not boot under Laravel. Plan for msgphp/eav + your own Doctrine layer, or accept a day of porting.@ORM\Entity the library classes. They're mapped superclasses; Doctrine will throw on duplicate/undefined mappings. Always subclass and register the subclass in the class map.msgphp_eav.class.attribute, the bundle resolves to the library class and you get a mapping error at cache warmup, not at runtime — easy to miss in CI.msgphp/domain's DomainId; the DBAL type registration lives in msgphp/domain-bundle. In Laravel you must register DomainIdType / the concrete id types yourself in your Doctrine config, or IDs silently persist as strings and queries miss.$value is a string column. Comparisons (WHERE v.value > 10) will sort lexicographically. Cast in SQL or store numeric metadata separately.orphanRemoval + cascade={"persist"} on the inverse side is what you want; the library gives you no cascade policy, so decide it per aggregate.AttributeValue holds both attributeId and an attribute association. Keep them in sync — constructing with the id but never setting the association breaks getAttribute() and your WITH a.name = ... joins.composer why-not against your PHP and doctrine/orm versions first; a transitive conflict here is a multi-hour rabbit hole and there is no upstream to patch it.bin/console doctrine:schema:validate (or php artisan doctrine:schema:validate) catches superclass mistakes fastest; doctrine:migrations:diff then --dry-run before touching a populated EAV table (a dropped attribute_id column loses data irreversibly).class: map (swap entities), custom repositories on your subclass, and decorating the bundle's factories in the container. There is no event system, no plugin API — don't go looking.attributes / attribute_values pair (or spatie/laravel-schemaless-attributes, or Postgres jsonb) gives you 90% of this package with a tenth of the integration cost. Use msgphp/eav when you specifically want its Attribute/AttributeValue/AttributeMetadata model and already run Doctrine.How can I help you explore Laravel packages today?