yokai/doctrine-value-object
yokai/doctrine-value-object brings reusable value objects to Doctrine in Laravel apps, offering embeddable types and integration helpers for cleaner, type-safe domain models with less boilerplate.
This is a Doctrine ORM package, not an Eloquent one. In a Laravel app you'll need Doctrine wired up first (laravel-doctrine/orm or a standalone EntityManager bound in a service provider); on an Eloquent-only codebase, stop here and use Attribute casts instead.
composer require yokai/doctrine-value-object
Where to look first, in order:
src/ValueObject.php — the toValue() / fromValue() contract every value object implements.src/StringValueObject.php (and the Integer/Float/Boolean siblings) — the base classes you'll actually extend.src/Doctrine/ — the #[ValueObject] attribute and the event subscriber that converts between the DB scalar and your object. Read these two before mapping anything.First use case: collapse a primitive into a domain type.
use Yokai\DoctrineValueObject\StringValueObject;
final class Email extends StringValueObject
{
public function __construct(string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
parent::__construct($value);
}
}
use Yokai\DoctrineValueObject\Doctrine\Attribute\ValueObject;
class User
{
#[ORM\Column(type: 'string', length: 180)]
#[ValueObject(Email::class)]
private Email $email;
}
Then register the package's Doctrine event subscriber on the EntityManager's event manager (automatic under Symfony; manual in Laravel). Without that registration the attribute is inert and hydration will fail.
Validation lives in the constructor. The base class stores the raw scalar; your subclass enforces invariant rules and passes the clean value up. Because the subscriber reconstructs via fromValue()/constructor during hydration, bad data already in the DB will throw at postLoad time — which is the point, but plan a migration/backfill if you're retrofitting existing tables.
Match the column type to toValue(). StringValueObject → string, IntegerValueObject → integer, etc. The Doctrine mapping stays a plain scalar column; the value object never gets its own DBAL type, which keeps the schema readable and query-able.
Nullable columns. If the column is nullable, guard in the value object or use a nullable-typed property. The subscriber will pass null through, so a non-nullable constructor param will blow up on rows with NULL.
Custom / composite objects. For something the base classes don't cover (a Money, a coordinate pair), implement the ValueObject interface directly with toValue() and fromValue(), store the composite as a JSON or delimited string column, and point #[ValueObject(YourClass::class)] at it. This is the main extension seam.
Collections. Store an array of scalars in a json/simple_array column and wrap it in a value object whose fromValue() rehydrates the members. Keep it small — Doctrine's change tracking only sees the property as a whole.
Testing. Boot an in-memory SQLite EntityManager in the test, register the subscriber exactly as you do in production, and assert find() returns real value objects and flush() persists the primitive. That catches the "forgot to register the subscriber" class of bug immediately.
EventManager. If your app is Eloquent-first, the equivalent is protected $casts = ['email' => EmailCast::class] with CastsAttributes — different trade-off, same idea, and worth writing yourself rather than pulling this in.Email and PHP throws a TypeError deep in the hydrator. First debugging step is always "is the subscriber attached to this manager?" — log getEventManager()->hasListeners(Doctrine\ORM\Events::postLoad).postLoad only. Objects built with new, returned from getReference(), or materialised through DQL partials bypass the listener. Same for merge() in some configurations. Test those paths if you rely on them.readonly/immutable, but mutable-proxy entities and readonly properties interact badly. Keep the property non-readonly on the entity side and expose a changeEmail() method that reassigns.composer.json and read src/Doctrine/ yourself — it's a few hundred lines and you may end up copying the subscriber into your app if the package stalls.How can I help you explore Laravel packages today?