Embeddables and Value Objects in Doctrine
AI generated
SF
{ }
Symfony · Doctrine ORM · Domain-Driven Design
Embeddables and Value Objects in Doctrine
Domain-driven design directly in your ORM

Primitive obsession, meaning representing business concepts as bare strings and integers, is one of the most common sources of unclear domain code. Embeddables turn value objects like Money or Address into first class citizens in Doctrine mapping, without requiring their own database tables.

18 min read Embeddable · Value Object · Domain-Driven Design Symfony 7 · Doctrine ORM 3 · PHP 8.4

1. Value objects versus entities and primitive obsession

An entity in Doctrine has its own identity, defined through its ID, and stays the same entity throughout its entire lifecycle, even as its attributes change. A value object, in contrast, has no identity of its own, it is defined exclusively by its values. Two Money objects with the same amount and the same currency are identical, regardless of where they were created in code. This distinction is the starting point for embeddables and value objects in Doctrine.

Without value objects, business concepts often end up as bare primitives in code, a phenomenon known as primitive obsession. A price gets stored as a float, an address as four separate string fields, an email address as a plain string with no validation guarantee. The problem: the business logic that belongs to these values, rounding rules for monetary amounts, format checks for email addresses, country validation for addresses, spreads across the entire codebase instead of living in one place.

Embeddables and value objects solve this problem by encapsulating business concepts as their own immutable classes, which are still transparently mapped onto several columns of the same database table, without needing their own table or a join. From the database's perspective, nothing changes, from the domain code's perspective, the application gains significant expressiveness and type safety.

2. Defining an embeddable class

A Doctrine embeddable class is marked with the #[ORM\Embeddable] attribute and structurally behaves like a normal PHP class with Doctrine field mappings, but has no ID and no table of its own. The fields inside the embeddable class are annotated with the regular #[ORM\Column] attribute, exactly as with an entity.


<?php

declare(strict_types=1);

namespace App\ValueObject;

use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;

#[ORM\Embeddable]
final readonly class Money
{
    #[ORM\Column(type: 'integer')]
    private int $amountInCents;

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

    public function __construct(int $amountInCents, string $currency)
    {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('Amount cannot be negative');
        }

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

    public function amountInCents(): int
    {
        return $this->amountInCents;
    }

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

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Cannot add different currencies');
        }

        return new self($this->amountInCents + $other->amountInCents, $this->currency);
    }
}

This embeddable class for Money encapsulates both the data representation and the business logic, here the rule that only amounts in the same currency can be added. This rule would be enforceable nowhere centrally with a purely primitive solution using two separate columns price and currency.

3. Embedding into the entity with a column prefix

To use an embeddable in an entity, the #[ORM\Embedded] attribute is set on the corresponding property. The optional columnPrefix parameter controls what the column names look like in the database, which becomes important once several embeddables of the same type are used in one entity, for example a billing and a shipping address.


<?php

declare(strict_types=1);

namespace App\Entity;

use App\ValueObject\Money;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 200)]
    private string $name;

    #[ORM\Embedded(class: Money::class, columnPrefix: 'price_')]
    private Money $price;

    public function __construct(string $name, Money $price)
    {
        $this->name = $name;
        $this->price = $price;
    }

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

    public function withPrice(Money $newPrice): self
    {
        $clone = clone $this;
        $clone->price = $newPrice;
        return $clone;
    }
}

Doctrine automatically generates the columns price_amount_in_cents and price_currency in the product table. From the domain code's perspective, however, $product->price() remains a full fledged Money object with all of its business logic, not two loose scalar values. That is exactly the practical benefit of embeddables and value objects: the database sees flat columns, the code sees rich objects.

4. Custom value types: Money, Address, EmailAddress

Alongside Money, addresses are a classic example for embeddables and value objects. An address typically consists of street, postal code, city and country, four values that always appear together and should be validated together. As a standalone Address embeddable class, this validation can be enforced centrally in the constructor, instead of being repeated everywhere in the code where an address is created.

An EmailAddress embeddable class with format validation in the constructor ensures that an invalid email address never ends up in the database, because validation happens at the single place where such an object can be created. This guarantee is stronger than form level validation, because it also applies to direct object creation in domain code, for example imports or internal services that never go through a form.

5. Nested embeddables

Doctrine has supported nested embeddables for several versions, meaning an embeddable that itself contains another embeddable. An OrderLine embeddable, for example, could contain both a Money field for the unit price and a Quantity field for the amount, while OrderLine itself gets embedded into an Order entity, or is referenced as an element of a collection.

With nested embeddables and value objects, the column prefixes of the individual nesting levels add up, which quickly leads to long column names. A well thought out prefix scheme, short but unambiguous abbreviations instead of full class names, keeps generated column names within the length limits of common database systems and readable for manual SQL queries.

6. Immutability and validation in the constructor

A central design principle for embeddables and value objects is immutability. With PHP 8.4 this can be enforced elegantly through readonly properties and final readonly class, as shown in the Money example above. Changing a value always creates a new object instead of mutating the existing state, which reliably prevents side effects in complex object graphs.

Validation for embeddables and value objects consistently belongs in the constructor, not in separate validator classes that must be called afterwards. A Money object that was constructed successfully is by definition always valid, it cannot contain a negative amount or an invalid currency. This guarantee, often called "make illegal states unrepresentable", is one of the strongest advantages of value objects over plain primitives, where invalid states are always possible.

7. Querying: DQL on embedded fields

A common misconception about embeddables and value objects is assuming that embedded fields cannot be queried directly. In fact, DQL supports access to embeddable fields via dot notation, exactly as with regular entity fields.


<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Product;
use Doctrine\ORM\EntityRepository;

/**
 * @extends EntityRepository<Product>
 */
final class ProductRepository extends EntityRepository
{
    /**
     * @return Product[]
     */
    public function findAffordableInCurrency(int $maxAmountInCents, string $currency): array
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.price.amountInCents <= :max')
            ->andWhere('p.price.currency = :currency')
            ->setParameter('max', $maxAmountInCents)
            ->setParameter('currency', $currency)
            ->orderBy('p.price.amountInCents', 'ASC')
            ->getQuery()
            ->getResult();
    }
}

The dot notation p.price.amountInCents is transparently translated by Doctrine into the correct column, price_amount_in_cents in our example. For the developer, it stays completely invisible that behind price there is an embeddable made of two columns instead of a single one, which ensures the composability of embeddables and value objects with the rest of the QueryBuilder ecosystem.

8. Database mapping and migration

From a database perspective, an embeddable does not create a new table or a join, only additional columns in the table of the embedding entity. That means a migration adding a new embeddable field is structurally identical to adding several individual columns, only that Doctrine automatically generates the column names based on the column prefix.

An important pitfall when refactoring: if an already existing primitive field is later converted into an embeddable, the column name almost always changes because the column prefix mechanism kicks in. A carefully written migration using RENAME COLUMN instead of DROP plus ADD prevents data loss here, an aspect closely tied to the general best practices for Doctrine Migrations.

9. Embeddables compared directly

The following table compares embeddables with other approaches to representing business concepts in Doctrine mapping, and shows when each approach is the right choice.

Approach Own table Identity Typical use case
Entity Yes Own ID across the lifecycle Customer, order, product
Embeddable No None, only value equality Money, Address, EmailAddress
Custom DBAL type No, a single column None A single scalar value with logic
Raw primitives No None Primitive obsession, no central validation

The decisive difference between an embeddable and a custom DBAL type lies in the number of columns: an embeddable represents a concept with several values that belong together, a custom type transforms a single scalar value. Money with amount and currency is a classic embeddable, a single encrypted string would rather be a custom type.

Mironsoft

Symfony architecture, domain-driven design and Doctrine modeling

Need to fix primitive obsession in your domain model?

We identify business concepts in your codebase, model them as clean value objects and embeddables, and guide the migration of existing entities without data loss.

Domain analysis

Identify primitive obsession in your existing code

Value object design

Immutable embeddables with central validation

Migration support

Safe conversion of existing columns without data loss

10. Summary

Embeddables and value objects solve primitive obsession by encapsulating business concepts like monetary amounts, addresses or email addresses as standalone, immutable classes, without creating an additional database table. The #[ORM\Embeddable] attribute marks the value type class, #[ORM\Embedded] with columnPrefix binds it to an entity, and DQL dot notation still allows direct querying on the embedded fields.

Immutability through readonly properties and validation in the constructor ensure that invalid states cannot arise in the first place. Nested embeddables allow more complex value type compositions, but require a well thought out prefix scheme to keep column names readable. Applying value objects consistently for groups of values that belong together gains type safety and central validation, without sacrificing the simplicity of the relational schema.

Embeddables and value objects in Doctrine — the key facts at a glance

Attributes

#[ORM\Embeddable] on the value type class, #[ORM\Embedded] with columnPrefix on the entity property.

Immutability

final readonly class plus validation in the constructor, so invalid states stay unrepresentable.

Querying

DQL dot notation like p.price.amountInCents works transparently on embedded fields.

Database impact

No new table, only additional columns in the embedding entity's table.

11. FAQ: Embeddables and Value Objects in Doctrine

1Entity vs. embeddable?
Entity has its own ID and table. Embeddable has no identity and only creates additional columns.
2Own table required?
No, only additional columns in the embedding table, no join.
3Avoiding column name collisions?
With the columnPrefix parameter, for example billing_ and shipping_ for two Address embeddables.
4Filtering directly on embeddable fields?
Yes, via dot notation like p.price.amountInCents in DQL.
5Why immutable?
Prevents side effects, makes value equality predictable, changes produce new objects.
6Where to place validation?
In the constructor, so invalid states cannot arise in the first place.
7Nested embeddables possible?
Yes, with summed column prefixes across nesting levels.
8Embeddable vs. custom DBAL type?
Embeddable represents multiple columns, custom type transforms a single scalar value.
9Safe migration from primitive to embeddable?
Use RENAME COLUMN instead of DROP plus ADD, since the column name changes.
10Do embeddables fully solve primitive obsession?
Yes for grouped values, but true entities with identity still need a regular entity.