upscale/doctrine-value-object-bundle
A Symfony bundle that simplifies persisting value objects with Doctrine. It ships DBAL types and mapping helpers so value objects can be stored directly in entities, eliminating repetitive primitive conversion code and keeping domain models expressive, type-safe, and clean.
Install the bundle (Symfony Flex will register it automatically; otherwise add Upscale\DoctrineValueObjectBundle\DoctrineValueObjectBundle to config/bundles.php):
composer require upscale/doctrine-value-object-bundle
Implement the underlying contract. The bundle builds on yokai/doctrine-value-object, so your value objects implement Yokai\DoctrineValueObject\ValueObject:
final class Email implements ValueObject
{
private function __construct(private string $value) {}
public static function fromValue($value): self
{
return new self((string) $value);
}
public function toValue(): string
{
return $this->value;
}
}
Define a DBAL type. Extend the base type from the library and tell it which value object it manages:
final class EmailType extends AbstractValueObjectType
{
public function getName(): string { return 'email'; }
protected function getValueObjectClass(): string { return Email::class; }
}
Register the type. Either let the bundle autoconfigure the service (tag it, or rely on the bundle's _instanceof rules), or list it explicitly under doctrine.dbal.types:
doctrine:
dbal:
types:
email: App\Doctrine\Type\EmailType
Use it in an entity:
#[ORM\Column(type: 'email')]
private Email $email;
Where to look first: the bundle's DependencyInjection/ and config/ directories (to see how types are auto-registered and tagged), then the yokai/doctrine-value-object README for the exact ValueObject / base-type contract. The bundle README is usually a short "zero-config" example.
First use case: a Email, Uuid, or Money column you currently model as a primitive string/int, but want typed everywhere — request payloads, service layer, and entity — without a getter/setter pair and without a custom hydrator.
cache:clear, done.doctrine.dbal.types is worth it when you need deterministic ordering, use types in tests, or ship a library that other apps will include.$qb->andWhere('u.email = :email')
->setParameter('email', new Email('[email protected]'), 'email');
null round-tripping; if you write your own convertTo* methods, handle null in both directions.Money(amount, currency) into a type. Use #[ORM\Embeddable] instead.bootstrap.php / test kernel before the connection is created. In-memory SQLite works fine and is the cheapest way to prove a type round-trips.Unknown column type 'email'. Use doctrine.dbal.types or a boot()-time / compiler-pass registration — never a controller or runtime listener.bin/console cache:clear. A stale compiled container is the #1 cause of "type suddenly not found".getName() must be unique and stable. Avoid collisions with built-ins (string, json, uuid) and prefer a namespace prefix (app_email) if you might ever shadow a third-party type.===; two equal Email instances from different request flows won't match. Either compare via toValue() in your entity logic, or rely on Doctrine's equals/compare support.JsonSerializable on the value object or add an accessor; otherwise serializers render an empty object.fromValue() or the canonical constructor. Do not rely on reflection-based instantiation — it bypasses your validation.bin/console doctrine:schema:validate and bin/console debug:container --tag=<your-type-tag> to confirm the bundle actually picked the service up. The bundle is thin; most bugs live in the base type's convertTo* methods.composer.json and read the type's convertToDatabaseValue / convertToPHPValue before it lands on a production schema.How can I help you explore Laravel packages today?