- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have a Money value object. Amount plus currency, immutable, with an add() method that refuses to mix EUR and USD. It is the kind of small class that keeps a domain honest.
Then you go to persist an Order that holds one. The first instinct is a second table: order_money with an order_id foreign key, an amount, and a currency. Now every read of an order carries a join. Every list query fans out. And a concept that is conceptually part of the order (its total) lives in a row somewhere else, reachable only through a relation.
That is the wrong shape. A Money is not an entity. It has no identity, no lifecycle of its own, no reason to exist apart from the order it belongs to. Doctrine has a mapping built for exactly this: the embeddable. It flattens a value object into columns on the owning table. No join, no second entity, and the value object stays a first-class citizen of the domain.
The value object
Start with a plain Money. No Doctrine imports yet — that is the point.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
final readonly class Money
{
public function __construct(
public int $amountMinor,
public string $currency,
) {
if ($amountMinor < 0) {
throw new \InvalidArgumentException(
'amount cannot be negative'
);
}
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
'cannot add different currencies'
);
}
return new self(
$this->amountMinor + $other->amountMinor,
$this->currency,
);
}
}
Amount in minor units (cents), currency as an ISO code. The invariants live in the constructor. Nothing here knows a database exists.
Marking it embeddable
Two attributes turn this into something Doctrine can flatten: #[Embeddable] on the class, #[Column] on each field it should map.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Embeddable]
final readonly class Money
{
public function __construct(
#[ORM\Column(type: 'integer')]
public int $amountMinor,
#[ORM\Column(type: 'string', length: 3)]
public string $currency,
) {
if ($amountMinor < 0) {
throw new \InvalidArgumentException(
'amount cannot be negative'
);
}
}
// add() unchanged
}
If you keep a hard line between domain and infrastructure, you will not want ORM attributes on a domain class at all. Doctrine still supports XML mapping for embeddables, so the value object stays pure and the mapping lives in a Money.orm.xml file. Both work. The attribute version is shorter to show, so the rest of this post uses it — swap in XML if your architecture forbids the imports.
Embedding it in the entity
The owning entity declares the field with #[Embedded]. Doctrine does not create a table for Money. It adds the value object's columns to the orders table.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Shared\Money;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'orders')]
final class Order
{
#[ORM\Id]
#[ORM\Column(type: 'string')]
private string $id;
#[ORM\Embedded(class: Money::class)]
private Money $total;
public function total(): Money
{
return $this->total;
}
}
Run the schema tool and the generated table looks like this:
CREATE TABLE orders (
id VARCHAR(255) NOT NULL,
total_amountMinor INT NOT NULL,
total_currency VARCHAR(3) NOT NULL,
PRIMARY KEY(id)
);
No order_money table. No foreign key. No join on read. The total is two columns sitting right next to the order it describes, and in PHP it hydrates back into a Money object with its add() method intact.
By default Doctrine prefixes each column with the field name plus an underscore — total_amountMinor, total_currency. That default is why you can embed the same value object twice without a collision.
Column prefixes, and the two-of-the-same problem
Say an order carries both a subtotal and a shipping cost. Both are Money. If Doctrine used the value object's own field names without a prefix, the second embed would try to create amountMinor a second time and the schema would break.
The columnPrefix argument controls the naming. Give each embed its own prefix and the collision disappears.
#[ORM\Embedded(class: Money::class, columnPrefix: 'subtotal_')]
private Money $subtotal;
#[ORM\Embedded(class: Money::class, columnPrefix: 'shipping_')]
private Money $shipping;
That yields subtotal_amountMinor, subtotal_currency, shipping_amountMinor, shipping_currency on one table. Two instances of the same value object, cleanly separated.
You can also set columnPrefix: false to drop the prefix entirely and map the value object's fields as bare column names. That reads well for a single embed (amountMinor instead of total_amountMinor), but it removes your safety net the day someone adds a second embeddable that shares a field name. For anything you expect to reuse, keep an explicit prefix.
An Address value object shows the same pattern with more fields:
#[ORM\Embeddable]
final readonly class Address
{
public function __construct(
#[ORM\Column]
public string $street,
#[ORM\Column]
public string $city,
#[ORM\Column(length: 2)]
public string $countryCode,
#[ORM\Column(name: 'postal_code')]
public string $postalCode,
) {}
}
Embed it as billing and shipping and you get eight columns on one row, each pair distinguished by its prefix:
#[ORM\Embedded(class: Address::class, columnPrefix: 'billing_')]
private Address $billing;
#[ORM\Embedded(class: Address::class, columnPrefix: 'shipping_')]
private Address $shipping;
The nullability trap
Here is the part that catches people, and it catches them in production.
Doctrine embeddables cannot be null. There is no such thing as a null embeddable in Doctrine's model. An #[Embedded] field is always an object. So you cannot write private ?Address $shipping = null and expect Doctrine to hand you null when the address is absent.
What actually happens is subtler and worse. If you make every column inside the embeddable nullable, Doctrine will still build an object on hydration. When all the underlying columns are NULL, you get an Address instance whose every property is null — an object that claims to be an address but holds nothing. Your readonly non-nullable property types will fight that, because a string $city cannot hold null, and you get a typed-property error deep inside hydration instead of a clean signal that the address is missing.
There is a long-standing issue in the Doctrine tracker about exactly this: embeddables and null semantics do not mix. The ORM has no way to say "this whole value object is absent."
You have two honest ways around it.
Option one: make the value object mandatory. If an order always has a shipping address, model it that way. Non-null columns, no ambiguity. Most "optional" value objects turn out to be mandatory once you ask the domain a direct question.
Option two: use a Null Object. When absence is genuinely part of the domain, represent it explicitly instead of leaning on SQL NULL. Give the value object a named empty state and a method to ask about it.
#[ORM\Embeddable]
final readonly class Address
{
public function __construct(
#[ORM\Column]
public string $street,
#[ORM\Column]
public string $city,
#[ORM\Column(length: 2)]
public string $countryCode,
#[ORM\Column(name: 'postal_code')]
public string $postalCode,
) {}
public static function none(): self
{
return new self('', '', '', '');
}
public function isEmpty(): bool
{
return $this->street === ''
&& $this->city === '';
}
}
Now the columns are non-nullable, hydration always produces a valid object, and "no address yet" is a state the domain names out loud rather than a NULL you trip over. Callers ask $order->shipping()->isEmpty() instead of null-checking a property that Doctrine was never going to hand them cleanly.
Pick one deliberately. The failure mode is picking neither (nullable columns plus non-null property types) and finding out during a hydration error six months later.
Where embeddables end
Embeddables are for value objects that flatten into their owner's row. They stop being the right tool the moment you need something a column cannot give you:
-
A collection of value objects. Order line items are a
list<LineItem>, and a list does not flatten into fixed columns. That is aOneToManyto a real table, or a JSON column with a custom type. -
Querying across the value object as if it were a table. You can filter on
total.currencyin DQL because it is a column, which is fine. But if you find yourself wanting to join to the value object, it wanted to be an entity. - Sharing one value object instance across many owners. Embeddables are copied into each row. If two orders must point at the same address record and see each other's edits, that is identity, and identity means an entity with a relation.
Line items are the usual place this line gets crossed. A Money embeds. A basket of items does not. Keep the flat things flat and let the relational things be relational.
The payoff of getting this right is that Money and Address stay real objects everywhere in the code. The controller receives a Money, the use case reasons about a Money, the domain enforces its rules on a Money, and persistence stores it as two plain columns with no join and no ceremony. The value object never degrades into a pair of loose scalars that anyone can recombine wrongly.
A value object is a modeling decision. How it lands in a table is a persistence detail. Embeddables let you honor the first without paying for a second table, and the nullability trap is the one place the abstraction leaks — so name your absent states and the leak closes.
Keeping Money and Address as first-class value objects, while persistence quietly flattens them into columns, is the whole game of keeping the framework at the edge instead of at the center. The domain speaks in value objects; Doctrine is one adapter that happens to know how to store them. That separation (domain in the middle, ORM as a replaceable detail) is what Decoupled PHP builds chapter by chapter.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)