Скалярний тип описує форму даних, але нічого не каже про їхній зміст. string $email — це будь-який рядок, зокрема порожній, з пробілом або з двома собаками; float $price — будь-яке число, зокрема відʼємне й з похибкою округлення. Тому валідація розповзається: перевірка у формі, ще одна в сервісі, третя перед відправкою в платіжний шлюз, і всі трохи різні. Value object згортає це в одну точку: інваріант перевіряється в конструкторі, а якщо конструктор відпрацював — невалідного екземпляра в системі не існує. Далі тип у сигнатурі працює як доказ: побачивши Email $to, ви вже знаєте, що всередині не сміття, і повторна перевірка зайва.
Друга властивість — відсутність ідентичності. Value object повністю визначається своїм вмістом: двісті гривень нічим не відрізняються від інших двохсот гривень, тому їх можна вільно замінювати одне одним. Через це VO роблять незмінним: у PHP це readonly-властивості з 8.1 або readonly class з 8.2, а «зміна» повертає новий екземпляр (add(), withCurrency()). Незмінність прибирає цілий клас багів із розділюваним станом: обʼєкт, переданий у три сервіси, не може бути зіпсований одним із них. Памʼятайте про межу readonly: воно захищає саме посилання, тож обʼєкт усередині VO теж має бути незмінним, інакше гарантія фіктивна.
Третя властивість — рівність за значенням, і саме тут найчастіше помиляються. === для обʼєктів порівнює ідентичність, тому два однакові Money будуть нерівні. == порівнює клас і всі властивості рекурсивно, що для простого VO спрацює, але залежить від внутрішніх деталей і вкладених обʼєктів. Тому пишуть явний equals(), який ще й фіксує правило: наприклад, для Email домен зазвичай нечутливий до регістру, і це рішення має бути в коді, а не в чиїйсь голові. Окремо: PHP не дозволяє обʼєкт як ключ масиву, тож для дедуплікації VO беруть його рядкове представлення.
Практичний виграш крім валідації — поведінка, якій нарешті є куди переїхати. Форматування суми, конвертація валют, порівняння цін, нормалізація телефону — усе це раніше жило статичними хелперами й дублювалося; у VO воно лежить поруч із даними, які описує, і покривається тестами без бази. Типовий кандидат: гроші, email, телефон, слаг, період дат, координати, ідентифікатор із форматом (наприклад, IBAN або ЄДРПОУ). Ознака, що VO потрібен: значення мандрує через кілька шарів і в кожному його перевіряють чи форматують заново.
Ціна теж реальна. VO треба мапити на сховище: у Doctrine ORM це #[Embeddable] плюс #[Embedded] у сутності або власний DBAL-тип, в Eloquent — кастомний каст на CastsAttributes, чий set() може повернути масив і розкласти обʼєкт у кілька колонок. Його треба серіалізувати на межах системи: у JSON-відповіді, у payload черги, у форму — тож toString()/fromString() пишуться одразу. І VO не безкоштовний за памʼяттю, тому в гарячих циклах на сотні тисяч ітерацій скаляр із перевіркою на вході чесніший. Правило межі просте: обгортка без інваріанта — це шум, а обгортка з інваріантом окупається з першого ж місця, де ви змогли викинути валідацію.
/**
* Гроші: сума в мінорних одиницях (копійки), валюта — enum.
* float заборонений: 0.1 + 0.2 !== 0.3, а гроші мусять сходитись до копійки.
*/
final readonly class Money // readonly class — PHP 8.2+
{
private function __construct(
public int $amount, // копійки, не гривні
public Currency $currency, // enum, PHP 8.1+
) {
// Єдине місце, де перевіряється інваріант: далі тип сам є гарантією
if ($amount < 0) {
throw new InvalidArgumentException('Сума не може бути відʼємною');
}
}
/** Іменований конструктор: назва пояснює одиниці виміру */
public static function fromMinorUnits(int $amount, Currency $currency): self
{
return new self($amount, $currency);
}
/** «Зміна» — це новий екземпляр, старий лишається недоторканим */
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
/** === порівняв би ідентичність, тому рівність описуємо явно */
public function equals(self $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency; // enum-кейси — синглтони
}
public function __toString(): string
{
return sprintf('%d.%02d %s', intdiv($this->amount, 100), $this->amount % 100, $this->currency->value);
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new DomainException('Не можна додавати різні валюти');
}
}
}
// Сигнатура сама себе документує і не дає переплутати аргументи місцями
$total = Money::fromMinorUnits(19900, Currency::UAH)->add(Money::fromMinorUnits(5000, Currency::UAH));
Сформулюйте вигоду через сигнатуру: `charge(Money $amount, Email $to)` неможливо викликати з переплутаними аргументами й неможливо викликати з невалідними даними, а `charge(float $amount, string $currency, string $email)` — можна, і компілятор мовчатиме. Далі згадайте `equals()` і мапінг у базу — це відрізняє того, хто VO писав, від того, хто про них читав.