Embeddables and Value Objects

Store a money or address object inside its owner's table, and get behaviour and invariants that three loose columns cannot have.

Syntax#[ORM\Embedded(class: Money::class, columnPrefix: 'total_')]

A value object is a small object with no identity of its own, defined entirely by its values. Two amounts of 10.00 EUR are the same money; there is no reason to tell them apart. An embeddable is Doctrine's way of storing such an object: the class is mapped with #[ORM\Embeddable], a property on an entity is mapped with #[ORM\Embedded], and its fields become extra columns in the entity's own table. No join, no second table.

Why not three columns

An amount and a currency as separate columns are two values that only mean something together, and nothing stops them drifting apart. Nothing can live with them either: adding two amounts, refusing to add EUR to USD, formatting for display. Those all become helper functions somewhere else. As one class, the pair is created together, validated in one constructor, and carries its own behaviour.

Columns and prefixes

By default Doctrine prefixes each embedded column, so an Address embedded as $address produces address_street, address_city and so on. #[ORM\Embedded(Money::class)] and #[ORM\Embedded(class: Money::class)] are the same attribute written two ways. Pass columnPrefix: 'total_' to choose the prefix, or columnPrefix: false to use the field names unchanged. Embedded fields are queryable with dot notation, as in WHERE o.total.currency = :currency.

The limits

An embeddable may only hold plain #[ORM\Column] fields. It cannot hold a relation to another entity. And it is always instantiated: there is no such thing as a null embeddable, so a row whose embedded columns are all NULL hydrates into an object whose properties are null.

Example

Example · php
<?php
// src/Entity/Order.php

declare(strict_types=1);

namespace App\Entity;

use App\Money\Money;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: '`order`')]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 20, unique: true)]
    private string $reference;

    // Shorthand and longhand of one attribute:
    //   #[ORM\Embedded(Money::class)]
    //   #[ORM\Embedded(class: Money::class)]
    // columnPrefix states the prefix instead of relying on the default, so
    // these two fields become total_amount and total_currency in this table.
    #[ORM\Embedded(class: Money::class, columnPrefix: 'total_')]
    private Money $total;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $placedAt;

    public function __construct(string $reference, Money $total, \DateTimeImmutable $placedAt)
    {
        $this->reference = $reference;
        $this->total = $total;
        $this->placedAt = $placedAt;
    }

    // The order never adds amounts itself; it asks the value object.
    public function addSurcharge(Money $surcharge): void
    {
        $this->total = $this->total->add($surcharge);
    }

    public function getTotal(): Money
    {
        return $this->total;
    }
}

When to use it

  • Replace amount and currency columns with one Money object that refuses to mix currencies.
  • Hold a billing and a shipping address of the same shape in one customer row.
  • Give a postcode or VAT number a single place for its format rule.

More examples

The Money embeddable

Everything about an amount of money is in one file: the two columns, the rule that they are constructed together, the refusal to mix currencies, and value equality. Note equals(): with no identifier, two instances are compared by value, not by ==. This class is mapped but it is not an entity, so it is never loaded or saved on its own.

Example · php
<?php
// src/Money/Money.php

declare(strict_types=1);

namespace App\Money;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Embeddable]
final class Money
{
    #[ORM\Column(type: Types::DECIMAL, precision: 12, scale: 2)]
    private string $amount;

    #[ORM\Column(length: 3)]
    private string $currency;

    public function __construct(string $amount, string $currency)
    {
        if (1 !== preg_match('/^-?\d+\.\d{2}$/', $amount)) {
            throw new \InvalidArgumentException('Amount must have two decimal places.');
        }

        $this->amount = $amount;
        $this->currency = strtoupper($currency);
    }

    // Returns a new Money: the object is never changed after construction.
    public function add(self $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new \DomainException('Cannot add different currencies.');
        }

        return new self(bcadd($this->amount, $other->amount, 2), $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->currency === $other->currency
            && 0 === bccomp($this->amount, $other->amount, 2);
    }

    public function getAmount(): string
    {
        return $this->amount;
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }
}

Using it, and what it returns

The mixed-currency bug cannot be written here: the only way to get a total is through add(), and add() refuses. With three loose columns that check lives in whichever service remembered to make it. Reach for this shape whenever two or three columns always travel together.

Example · php
<?php
// Illustrating Money on its own, outside any entity.

declare(strict_types=1);

use App\Money\Money;

$subtotal = new Money('19.99', 'EUR');
$shipping = new Money('5.01', 'eur');

$total = $subtotal->add($shipping);

echo $total->getAmount() . ' ' . $total->getCurrency();
// Output:
// 25.00 EUR

// The originals are untouched, because add() built a new object.
echo $subtotal->getAmount();
// Output:
// 19.99

var_dump($total->equals(new Money('25.00', 'EUR')));
// Output:
// bool(true)

try {
    $subtotal->add(new Money('5.00', 'USD'));
} catch (DomainException $e) {
    echo $e->getMessage();
}
// Output:
// Cannot add different currencies.

Two embeddables of the same class

One embeddable class, used twice in the same table, with distinct prefixes keeping the columns apart. This is the case a separate table cannot handle cheaply, and the case where a prefix is worth writing out: two embeddables of the same class must not collide, and an explicit prefix survives a property rename.

Example · php
<?php
// src/Entity/Customer.php

declare(strict_types=1);

namespace App\Entity;

use App\Address\Address;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Customer
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // Default prefixing derives the prefix from the property name, so it would
    // give billing_address_street and shipping_address_street here. Stating the
    // prefix keeps the column names stable if the property is ever renamed.
    #[ORM\Embedded(class: Address::class, columnPrefix: 'billing_')]
    private Address $billingAddress;

    #[ORM\Embedded(class: Address::class, columnPrefix: 'shipping_')]
    private Address $shippingAddress;

    public function __construct(Address $billingAddress, Address $shippingAddress)
    {
        $this->billingAddress = $billingAddress;
        $this->shippingAddress = $shippingAddress;
    }

    public function shipTo(Address $address): void
    {
        $this->shippingAddress = $address;
    }
}

Discussion

  • Be the first to comment on this lesson.