matthiasnoback/doctrine-orm-value-object
A Doctrine ORM integration for mapping PHP value objects as embeddables, with support for custom types and conversion between value objects and database columns. It helps keep domain models free of ORM concerns while persisting rich value objects transparently.
Install with Composer:
composer require matthiasnoback/doctrine-orm-value-object
The README is the only real documentation — read it before anything else. Then read these three classes, in order, to understand the whole machine:
ValueObjectMappings — the registry that says "value object class X is stored in column Y".ReflectionValueObjectSerializer — the default serializer; it reads private properties via reflection and reconstructs the object without calling its constructor.AbstractValueObjectType — the DBAL type that turns the object into a database string and back.The library solves one specific problem: Doctrine has no way to map an arbitrary value object to a column without writing a custom DBAL type per class. This package lets you declare mappings once, and a loadClassMetadata subscriber rewires the entity's field to a type backed by the serializer.
Minimal bootstrap shape:
$mappings = new ValueObjectMappings();
$mappings->add(EmailAddress::class, 'email'); // VO class => entity field
$mappings->add(Money::class, 'price');
// register the DBAL type(s) and the loadClassMetadata subscriber with
// your EntityManager configuration (see README for the exact wiring)
First use case: give an entity a private EmailAddress $email property with a getter/setter, register the mapping, run schema:update, and verify that persisting a new EmailAddress('[email protected]') writes a string column and re-hydrates into the value object on find().
Register mappings at bootstrap, before metadata loads. Put the ValueObjectMappings construction in your EM factory / service provider. Doctrine caches metadata; if a mapping is missing when metadata is first built, the field type will be wrong until you clear the cache.
Prefer a custom Serializer over reflection for domain objects. The reflection serializer is fine for quick prototypes, but it skips constructors and any validation. Implement the Serializer interface and round-trip through a named constructor so invariants hold:
final class EmailAddressSerializer implements Serializer
{
public function serialize($object): string
{
return (string) $object; // EmailAddress::__toString()
}
public function deserialize(string $value): object
{
return EmailAddress::fromString($value); // validates on the way in
}
}
For multi-property value objects, use json_encode of a canonical toArray() and a matching fromArray() factory.
Keep value objects flat. The reflection serializer handles scalar and array properties well; nested value objects and collections are where it gets fragile. If you have nesting, write the serializer explicitly.
Design around the single-column storage. The day-to-day cost is queryability: the column is an opaque string, so you cannot do WHERE email = :x in a portable way. Options:
LIKE '%…%' for coarse search.Test at two levels. Unit-test every serializer with a round-trip (deserialize(serialize($vo)) == $vo). Then a functional test that persists, flushes, refreshes, and asserts the value object came back intact — that catches metadata wiring problems the unit test can't.
__construct, so validation and invariants are silently skipped on hydration. For anything security- or money-related, supply a custom serializer.null explicitly. Both convertToDatabaseValue and convertToPHPValue receive null for nullable columns; make sure your serializer (and the base type) short-circuit it rather than passing null into serialize().@Embeddable / @Embedded, which handle value objects natively with per-field columns and full queryability. Reach for this package only when you deliberately want one serialized column, or you have many value objects and want to avoid a DBAL type class each. Also weigh that this library is small, low-adoption, and predates modern Doctrine — vet it against your ORM/DBAL versions before adopting.Type::getName() is deprecated/removed in DBAL 3, and ORM 3's metadata APIs differ; smoke-test a real persist/find before committing.EntityManager loads class metadata. Clear the metadata cache whenever you add a mapping.Type::hasType(...)), and confirm the loadClassMetadata subscriber actually runs. Most "it's not hydrating" bugs are a missing mapping or a cached metadata entry.Serializer interface (validation/formatting), AbstractValueObjectType (change serialization/column semantics), and the mappings registry (per-class type names). There is no plugin system beyond these — keep customizations inside your own serializer.How can I help you explore Laravel packages today?