sylius/metadata-bundle
Sylius Metadata Bundle adds flexible, arbitrary metadata to Sylius resources and entities, letting you extend data models without schema changes and keep custom attributes organized.
Know what you're installing before you install it. sylius/metadata-bundle is the Symfony DI/config glue around sylius/metadata, a Doctrine-backed key/value bag that attaches arbitrary metadata to any entity without adding columns to that entity's table. The repository is a read-only subtree split of Sylius/Sylius and is archived: no patch stream, no security response, no compatibility work. Treat it as "vendor a small library into my app", never as "adopt a maintained dependency."
The stack gate comes first. This is a Symfony bundle, not a Laravel package. The bundle layer is worth ~nothing outside a Symfony kernel — it assumes the Symfony container, Doctrine ORM mapping registration, and a Sylius-era version matrix. If you are on Laravel/Eloquent, skip to the Implementation Patterns adapter section: you are extracting a schema and a repository contract, not installing code. If you are already inside a Sylius application that has this bundle in the graph, the question is "how fast do we internalize it", not "should we adopt it".
Minimal steps inside a Symfony/Sylius app:
composer require sylius/metadata-bundle:^1.0
config/bundles.php:
Sylius\Bundle\MetadataBundle\SyliusMetadataBundle::class => ['all' => true],
Metadata entity. The config key is sylius_metadata, with a Sylius-style classes.metadata.model entry:
# config/packages/_sylius_metadata.yaml
sylius_metadata:
classes:
metadata:
model: App\Entity\Metadata
@ORM\Entity / table mapping the bundle deliberately leaves off:
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Sylius\Bundle\MetadataBundle\Entity\Metadata as BaseMetadata;
/** @ORM\Entity(repositoryClass="App\Repository\MetadataRepository") */
/** @ORM\Table(name="app_metadata") */
class Metadata extends BaseMetadata {}
MetadataSubjectInterface (getMetadataIdentifier(): string plus the getMetadata() / setMetadata() collection accessors).Where to look first when something is off:
src/DependencyInjection/Configuration.php and SyliusMetadataExtension — the only real config surface (classes.metadata.model).src/Resources/config/doctrine/model/Metadata.orm.xml — authoritative column list: subject_id, subject_class, key, name, value.src/Subscriber/ — the Doctrine event subscriber is where the magic lives. It fills subject_id/subject_class on flush and hydrates the subject's collection on load. Read it before debugging anything.src/Form/Type/ — the collection form type for editing metadata rows.First useful use case: let support staff tag a Product or Customer with free-form attributes (CRM id, internal flags, SEO/OpenGraph overrides) without a migration per attribute. Write a Metadata row with key = 'crm_id', value = '...', and read it back through the subject's metadata collection.
One Metadata entity per app, always via the mapped superclass. Do not map the bundle's class directly. Subclass so you own the table name, the indexes, and the repository. Add a composite index on (subject_class, subject_id) — that is the query you will run constantly, and the bundle does not supply a query strategy, only the mapping.
The subject contract is the whole API surface. The subscriber hooks loadClassMetadata to add the collection association to any entity implementing the interface, then:
postLoad, lazily populates $subject->getMetadata() — usually a repository lookup, so watch the query count;subjectId / subjectClass from getMetadataIdentifier() / get_class() and persists new rows.Keep the identifier stable and deterministic. Return a string primary key (cast ints with (string)); for composite keys build a joined string with a fixed, documented format. Everything downstream — reads, orphaning, migrations — depends on this one method.
Write through the subject, not the repository. The intended workflow is to mutate the collection on the domain object ($product->addMetadata(...)) and let flush + the subscriber do the persistence. Going around it with a hand-built repository insert is how you end up with rows missing subject_class.
Read through the subject too, but mind the access pattern. getMetadata() after load is convenient for a single detail page and disastrous in a listing. If you need metadata for a collection of subjects, fetch it in one query keyed by (subject_class, subject_id IN ...) and hydrate manually — do not rely on postLoad in a loop.
Behind an internal interface, if you're reimplementing. The transferable insight is the split between a storage-agnostic metadata component and a thin integration layer. Reproduce it: define MetadataStore (or similar) in your domain, with one adapter per persistence choice — Doctrine JSON column, EAV table, Eloquent model, document store. Replacement then costs one adapter instead of a rewrite. Reimplementation against Eloquent is days, not weeks: a metadata table (subject_type, subject_id, key, value), a repository, and a trait or interface on the subject models.
Configuration and editing UI. The bundle ships a collection form type for metadata rows; if you fork, that is the piece to port alongside the entity — it encodes the "list of key/value rows with add/remove" UX that saves you re-deriving it. Wire it as a nested form type on your admin resource forms and submit the collection as a whole.
Provider/extension point. For computed or externally-sourced metadata (feature flags, third-party CRM lookups), prefer a provider that returns values at read time over writing rows eagerly. Persistent metadata should be things users or imports actually own.
getMetadataIdentifier() format leave rows pointing at subject_id values nothing resolves to. Metadata loss looks like a "disappeared field" bug weeks later. Pin the identifier format in a test and migrate rows deliberately when it changes.postLoad is the default failure mode. Hydrating the collection per subject in a listing or an API serializer will multiply your query count by the row count. Profile early; hydrate in bulk.classes.metadata.model is effectively required — the bundle maps a superclass, not a usable entity. Forgetting step 4 of Getting Started produces a confusing "no metadata table" failure, not a clear error.DependencyInjection/ against your Symfony version.value length and consider a JSON column where the database supports it.(subject_class, subject_id, key) should be unique if you mean "one value per key per subject" — otherwise nothing stops duplicate keys and your read path has to guess which row wins.Resources/config/doctrine/model/Metadata.orm.xml. Diff your generated schema against that XML before shipping, and treat the XML as the spec.subject_class, subject_id, key, value? (2) is the subject implementing the interface and has Doctrine loaded its metadata? (3) what does the subscriber do on postLoad / onFlush for this class? Most "metadata isn't saving" reports resolve at step 1 or 2, not in the subscriber.How can I help you explore Laravel packages today?