A Guide to Domain-Driven Design and Type Safety
Domain-Driven Design (DDD) has changed the way we structure modern software, shifting focus from database schemas to the core business logic. At the middle of this lies the Value Object, a small, immutable object that represents a descriptive aspect of the domain but lacks conceptual identity. While entities (like a User or Order) are defined by who they are, value objects (like an EmailAddress, Money, or GPSCoordinate) are defined by what they are.
In this tutorial, you will move beyond "Primitive Obsession", the anti-pattern of using simple types like strings and integers for complex domain concepts—and implement robust, type-safe Value Objects using the latest features of PHP 8.2 and 8.3. By the end, you will have a reusable toolkit for creating immutable, self-validating objects that make your code clearer, safer, and easier to test.
Prerequisites and Environment Setup
To follow this tutorial effectively, you should have the following:
- PHP 8.2 or higher: We will leverage
readonlyclasses, a feature introduced in PHP 8.2 that drastically simplifies creating immutable objects. - Composer: For managing dependencies.
- A basic understanding of OOP: Classes, interfaces, and visibility modifiers.
We will also use webmozart/assert, a lightweight library for assertions, to handle validation logic cleanly.
Step 1: Setting Up the Project
First, create a new directory for your project and initialise Composer.
mkdir php-value-objects
cd php-value-objects
composer init --name="tutorial/value-objects" --require="php:^8.2" -n
Next, install the assertion library. It is an industry standard for writing "guard clauses" in PHP.
composer require webmozart/assert
Create a src directory and configure autoloading in your composer.json so we can run our code easily.
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"require": {
"php": "^8.2",
"webmozart/assert": "^1.11"
}
}
Run composer dump-autoload to apply changes.
Part 1: The Problem with Primitive Obsession
Before writing the solution, let us look at the problem. Imagine you are building an e-commerce system. A standard User class might look like this:
<?php
namespace App;
class User
{
public string $email;
public string $currency; // 'USD', 'EUR', etc.
public float $balance;
public function __construct(string $email, string $currency, float $balance)
{
$this->email = $email;
$this->currency = $currency;
$this->balance = $balance;
}
}
Why is this dangerous?
- No Validation: The
$emailstring could be "hello world", which is not a valid email. You would need to checkfilter_varevery time you use it. - Type Safety Leaks:
$currencyis just a string. Pass "US_DOLLAR" instead of "USD", and your payment gateway fails. - Mutable State: Anyone can change
$user->balancedirectly without any audit trail or logic. - Floating Point Errors: Using
floatfor money is a cardinal sin in software development due to precision issues (e.g.,0.1 + 0.2 !== 0.3).
This is Primitive Obsession: representing domain concepts (Email, Money) with primitive types (string, float).
Part 2: Your First Value Object
Let us refactor the email address into a Value Object. A Value Object must satisfy three main properties:
- Immutability: Once created, it cannot change.
- Value Equality: Two objects are equal if their values are the same (not just if they are the same instance).
- Self-Validation: It cannot exist in an invalid state.
Using PHP 8.2 readonly Classes
PHP 8.2 introduced readonly classes, which automatically make all public properties read-only. This is perfect for Value Objects.
Create src/EmailAddress.php:
<?php
namespace App;
use Webmozart\Assert\Assert;
readonly class EmailAddress
{
public string $value;
public function __construct(string $value)
{
// 1. Sanitize input
$value = trim($value);
// 2. Validate input (Invariant)
Assert::email($value, 'Invalid email address provided.');
// 3. Assign value
$this->value = $value;
}
/**
* Comparison method for Value Equality
*/
public function equals(EmailAddress $other): bool
{
return $this->value === $other->value;
}
/**
* String conversion for easy usage
*/
public function __toString(): string
{
return $this->value;
}
}
Breakdown of the Code
readonly class: This ensures$valuecannot be modified after construction. We do not need to make propertiesprivatewithgettersanymore;public readonlyis safe and cleaner.- Guard Clauses:
Assert::emailthrows anInvalidArgumentExceptionif the format is wrong. This guarantees that if you have an instance ofEmailAddress, it is valid. You never have to checkis_valid_email()again in your controllers. __toString(): Allows the object to be cast to a string automatically, useful for database saving or UI display.
Part 3: Handling Compound Values (The Money Pattern)
Value Objects truly shine when a concept requires multiple pieces of data to be valid. "Money" is the classic example: it needs an Amount and a Currency.
Create src/Currency.php:
<?php
namespace App;
use Webmozart\Assert\Assert;
readonly class Currency
{
public string $isoCode;
public function __construct(string $isoCode)
{
$isoCode = strtoupper(trim($isoCode));
// Simple validation for example purposes.
// In production, check against a real list of ISO codes.
Assert::length($isoCode, 3, 'Currency code must be 3 characters.');
Assert::alpha($isoCode, 'Currency code must contain only letters.');
$this->isoCode = $isoCode;
}
public function equals(Currency $other): bool
{
return $this->isoCode === $other->isoCode;
}
}
Now, create src/Money.php. We will use integers to store the amount in "cents" (or the smallest unit) to avoid floating-point math errors.
<?php
namespace App;
use Webmozart\Assert\Assert;
readonly class Money
{
public function __construct(
public int $amount,
public Currency $currency
) {}
/**
* Business Logic: Adding Money
*/
public function add(Money $other): Money
{
// Guard Clause: Cannot add different currencies
if (!$this->currency->equals($other->currency)) {
throw new \InvalidArgumentException('Cannot add money of different currencies.');
}
// Return a NEW object (Immutability)
return new Money(
$this->amount + $other->amount,
$this->currency
);
}
public function equals(Money $other): bool
{
return $this->currency->equals($other->currency)
&& $this->amount === $other->amount;
}
public static function fromFloat(float $amount, Currency $currency): self
{
// Convert logic, e.g., dollars to cents
return new self((int) round($amount * 100), $currency);
}
}
The Power of Immutability
Notice the add method. It does not modify $this->amount. Instead, it returns new Money(...).
This mimics how primitive integers work. If you do $a = 5; $b = $a + 2;, $a is still 5. Value Objects should behave the same way. This prevents "spooky action at a distance" where changing a value in one part of your code accidentally affects another part that holds a reference to the same object.
Part 4: Refactoring the User Class
Now let us rebuild our User class using our new types.
<?php
namespace App;
class User
{
public function __construct(
public EmailAddress $email,
public Money $balance
) {}
public function deposit(Money $amount): void
{
// The User entity is mutable, but the Money VO is not.
// We replace the old Money instance with a new one.
$this->balance = $this->balance->add($amount);
}
}
Usage Example
Create a file index.php to test our logic:
<?php
require 'vendor/autoload.php';
use App\EmailAddress;
use App\Currency;
use App\Money;
use App\User;
try {
// 1. Valid Setup
$usd = new Currency('USD');
$initialBalance = new Money(0, $usd);
$email = new EmailAddress('[email protected]');
$user = new User($email, $initialBalance);
echo "User created: " . $user->email . "\n";
// 2. Perform Logic
$topUp = Money::fromFloat(50.00, $usd); // 5000 cents
$user->deposit($topUp);
echo "New Balance: " . ($user->balance->amount / 100) . "\n";
// 3. Immutability Check
$topUp->add(new Money(1000, $usd));
// $topUp is NOT changed here, the result was discarded.
// This proves safety.
// 4. Validation Failure
$badEmail = new EmailAddress('invalid-email'); // Throws Exception
} catch (\InvalidArgumentException $e) {
echo "Error: " . $e->getMessage() . "\n";
}
Output:
User created: [email protected]
New Balance: 50
Error: Invalid email address provided.
Part 5: Advanced Usage and Named Constructors
Constructors in Value Objects can sometimes become complex if they handle multiple input formats. A common pattern in DDD is to use Named Constructors (static factory methods) to make creation expressive.
Let's enhance EmailAddress with domain-specific factories.
// Inside EmailAddress class...
/**
* Creates an email from a user input form, potentially untrimmed.
*/
public static function fromUserInput(string $input): self
{
return new self($input);
}
/**
* Creates an admin email for a specific domain.
*/
public static function createAdminEmail(string $username): self
{
return new self($username . '@mycompany.com');
}
Private Constructors
To enforce the use of named constructors, you can make the __construct method private. However, with PHP 8.2 readonly properties, you often want the constructor public so hydration libraries (like Doctrine or serializers) can use it easily without reflection magic. A balanced approach is keeping the constructor simple (assignment & validation only) and using static methods for complex logic.
Part 6: Persistence with Doctrine
A common question is: "How do I save these objects to the database?" You don't want to save a serialized PHP object; you want to save the raw value (e.g., the string "[email protected]") into the users table.
Doctrine ORM handles this elegantly with Embeddables.
Mapping Value Objects
In Doctrine, you treat the Value Object as an @Embeddable and the entity using it has it @Embedded.
Money Class (Doctrine Attributes):
use Doctrine\ORM\Mapping as ORM;
#[ORM\Embeddable]
readonly class Money
{
#[ORM\Column(type: 'integer')]
public int $amount;
#[ORM\Column(type: 'string', length: 3)]
public string $currency; // Simplified for Doctrine example
public function __construct(int $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
}
User Entity:
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id, ORM\Column(type: 'integer'), ORM\GeneratedValue]
private int $id;
#[ORM\Embedded(class: Money::class)]
private Money $balance;
}
When Doctrine creates the table, it will "flatten" the Money object. The users table will have columns: balance_amount and balance_currency. When you fetch the User, Doctrine automatically reconstructs the Money object for you.
Pro Tip: Doctrine 2.11+ supports
readonlyproperties, but ensure you test your hydration strategy. If you face issues with older Doctrine versions, you may need to remove thereadonlykeyword from the property but keep the setter private.
Part 7: Common Mistakes and Troubleshooting
Mistake 1: Adding Identity
Don't give a Value Object an ID (like $id). If it has an ID, it’s an Entity. If two value objects have the same data, they are identical. You don't care which "5 dollars" you have, only that it is "5 dollars".
Mistake 2: Making them Mutable
Never add setters (e.g., setAmount). If you need to change the amount, create a new object.
- Bad:
$money->setAmount(100); - Good:
$money = new Money(100, $currency);
Mistake 3: Over-validating
Only validate invariants (rules that must always be true, like "email must have an @ symbol"). Do not validate context-specific rules (like "email must be unique in the database") inside the Value Object. Uniqueness is a service-level or repository-level concern, not a property of the string itself.
Troubleshooting: "Cannot modify readonly property"
If you see this error, you are likely trying to write to a property outside the constructor or trying to use __clone incorrectly. In PHP 8.3, readonly classes can be re-initialized during cloning using __clone method deep-cloning logic, but generally, you should prefer creating fresh instances.
Implementing Value Objects in PHP 8.2+ is a game-changer for code quality. By moving logic out of your controllers and entities into small, dedicated classes, you achieve:
- Code that speaks: Type hints like
distance(Coordinates $from, Coordinates $to)are self-documenting. - Safety: Invalid states become impossible. You stop writing
if (!empty($email))everywhere. - Testability: You can unit test the complex logic of
MoneyorDateRangein isolation, without mocking databases or HTTP requests.
Start small. Pick one concept in your application—like an Email, a SKU, or a Price—and refactor it into a Value Object. You will immediately see the clarity it brings to your domain logic.