- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Database Playbook: Choosing the Right Store for Every System You Build
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've shipped this bug. A controller takes $amount as a string from a JSON body, hands it to a service, which passes it to a repository. The repository casts it to float for arithmetic, then back to string for the SQL. Between controller and SQL, an EUR cart gets added to a USD discount. The total comes out plausible-looking. A customer pays the wrong number. Finance finds it three weeks later when the reconciliation report disagrees with Stripe.
The bug isn't in any single line of that path. It's in the type system, which had nothing to say. Amount, currency code, order id, email — every one of them a string. PHP shrugged and let every layer assume the previous layer had checked.
This post is the fix. Three real value objects in PHP 8.3: Money, Email, and OrderId. All written as final readonly class, with invariants enforced in the constructor. After the boundary, every caller can stop checking. The bugs you used to ship with strings stop being reachable.
Why strings are the wrong type for almost everything
A string can hold anything. That's the feature and the problem. "42.0" and "42" and "forty-two" and "" and " 42 " all type-check as string. Only one place knows the difference: the line of code that decided what string was supposed to mean. That knowledge does not travel.
So you write the same defensive code at every layer. The controller validates. The service validates again because the controller might be skipped (CLI, queue worker, test). The repository validates a third time because someone might call it from a job. Three checks for the same rule, and the fourth caller forgets one of them and ships a bug.
A value object solves this by making the type carry the rule. After new Email($input) succeeds, every function that takes an Email parameter knows the value parsed. After new Money(500, 'EUR') succeeds, no caller can mix it with USD by accident — the add method refuses. Validate once at the boundary. After that the type system enforces it.
PHP got the tools for this in 8.2 (readonly class) and 8.3 (typed class constants, deeper readonly semantics). The cost dropped enough that the old objection — too much boilerplate — no longer holds.
Money: the value object every PHP app gets wrong
Every senior PHP engineer has seen the same three Money bugs:
- Amount stored as
float.0.1 + 0.2 !== 0.3in IEEE 754. Cents drift over time. - Currencies added without checking. EUR plus USD equals a meaningless number.
- Multiplied by another
Moneyinstead of a scalar. Square dollars do not exist.
One file closes all three.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
final readonly class Money
{
public function __construct(
public int $amountInMinorUnits,
public string $currency,
) {
if ($amountInMinorUnits < 0) {
throw new \InvalidArgumentException(
'Money amount cannot be negative.'
);
}
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
throw new \InvalidArgumentException(sprintf(
'Currency must be a 3-letter ISO code, got "%s".',
$currency,
));
}
}
public static function zero(string $currency): self
{
return new self(0, $currency);
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self(
$this->amountInMinorUnits + $other->amountInMinorUnits,
$this->currency,
);
}
public function multiply(int $factor): self
{
if ($factor < 0) {
throw new \InvalidArgumentException(
'Cannot multiply Money by a negative factor.'
);
}
return new self(
$this->amountInMinorUnits * $factor,
$this->currency,
);
}
public function equals(self $other): bool
{
return $this->amountInMinorUnits === $other->amountInMinorUnits
&& $this->currency === $other->currency;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new \DomainException(sprintf(
'Currency mismatch: %s vs %s.',
$this->currency,
$other->currency,
));
}
}
}
Read the file for what it refuses. That is the point of the design.
int $amountInMinorUnits refuses floats. Five euros is 500. Half a cent does not exist in this system; if your business needs it, the type stops being int and starts being a dedicated Decimal value object, but it is never float. The int is the test: does the value the business cares about have whole-number minor units? Yes? Done.
string $currency is validated by a three-letter ISO regex in the constructor. After construction, every consumer knows $money->currency matches /^[A-Z]{3}$/. No defensive check needed downstream.
add(self $other) refuses currency mismatch by throwing. The assertSameCurrency call is the entire reason the class exists. A controller that receives a USD discount and tries to subtract it from an EUR cart cannot compile a wrong answer — the line throws, the request fails fast, the log entry names the rule.
multiply(int $factor) refuses to take another Money. The type signature is the rule. You cannot pass $money->multiply($otherMoney) because the type system refuses the call. Three times three is nine. Three euros times three euros is meaningless. The signature blocks it.
The class is final readonly. final because subclassing a value object is almost always a mistake (a DiscountedMoney extends Money would let a subclass add new state that breaks equality). readonly because every property is set once in the constructor and never again; PHP refuses reassignment at runtime, which means equality stays stable for the lifetime of the object.
The arithmetic returns new instances. $a->add($b) does not mutate $a. That is what makes Money safe to pass around: no caller can change the value out from under you.
Email: the type that means "we already checked"
Look at the production code most teams write.
public function sendWelcome(string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Bad email');
}
// ... build message, send
}
That check shows up at the top of sendWelcome, the top of addToMailingList, the top of resetPassword, the top of every method that takes an email. Six methods, six copies of the same regex. The seventh forgets it. That is the bug.
The fix is the same shape as Money. Make the type carry the rule.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
final readonly class Email
{
public string $value;
public function __construct(string $value)
{
$trimmed = trim($value);
if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(sprintf(
'Email is not a valid address: "%s".',
$value,
));
}
$this->value = strtolower($trimmed);
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
The constructor normalizes. Trimmed whitespace, lowercase. Two Email instances built from " Alice@Example.com " and "alice@example.com" are equal. That matters because users type their email in every casing variation possible, and your database has a UNIQUE constraint that does not agree with them. The value object does the normalization once, at the boundary, and the rest of the code does not have to remember.
The signature of every downstream function changes from function send(string $email) to function send(Email $email). A caller cannot pass a raw string; the type system refuses. A caller who already has an Email does not need to revalidate, because the only way to hold an Email is to have passed validation already. The defensive check at the top of sendWelcome is gone, because the parameter type does the work.
domain() is a method because "the part after the @" is a real concept the business cares about (rate-limit by domain, block free-mail providers, route corporate addresses differently). Putting it on the value object means every caller computes it the same way. Three callers all writing substr($email, strpos($email, '@') + 1) is three places where someone uses the wrong offset.
OrderId: not every string is a UUID
The third pattern, and the one that pays back fastest in code review.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use Ramsey\Uuid\Uuid;
final readonly class OrderId
{
public function __construct(public string $value)
{
if (!Uuid::isValid($value)) {
throw new \InvalidArgumentException(sprintf(
'OrderId must be a valid UUID, got "%s".',
$value,
));
}
}
public static function generate(): self
{
return new self(Uuid::uuid4()->toString());
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
The reward looks small on a single class. It compounds across the codebase. Three signatures from the same project:
function findOrder(string $id): ?Order
function refund(string $orderId, string $customerId): void
function attachInvoice(string $invoiceId, string $orderId): void
There is a real bug hiding in those signatures. refund takes the order id and customer id in that order. A caller who confuses them will pass valid UUIDs that point at the wrong rows, and the function will run to completion and return without an error. The defect surfaces hours later when the customer support team gets a refund for the wrong account.
With value objects:
function findOrder(OrderId $id): ?Order
function refund(OrderId $orderId, CustomerId $customerId): void
function attachInvoice(InvoiceId $invoiceId, OrderId $orderId): void
A caller who passes $customerId where $orderId is expected gets a type error at the call site. The bug becomes unrepresentable in the type system. The cost was one extra class per id. The refund-the-wrong-customer bug paid for that class fifty times over.
CustomerId, ProductId, InvoiceId, SkuCode are all the same code with different class names. It looks like repetition. It is repetition. The repetition is the asset — five different identities are five different types, and PHP refuses to mix them. Abstracting a single AbstractId base class to save the lines would couple CustomerId to OrderId and break the very property that makes them useful.
How the three classes change a checkout controller
Pre-value-object, the path looks like this:
public function checkout(Request $request): JsonResponse
{
$email = $request->input('email');
$amount = (float) $request->input('amount');
$currency = $request->input('currency');
$orderId = $request->input('order_id');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return response()->json(['error' => 'bad email'], 422);
}
if ($amount <= 0) {
return response()->json(['error' => 'bad amount'], 422);
}
// ... three more checks
$this->service->charge($orderId, $email, $amount, $currency);
return response()->json(['ok' => true]);
}
The charge method then either trusts the controller (every other caller now has to check the same things) or re-validates (the controller's work is wasted). Either choice ships bugs.
With value objects:
public function checkout(Request $request): JsonResponse
{
try {
$orderId = new OrderId($request->input('order_id'));
$email = new Email($request->input('email'));
$price = new Money(
(int) $request->input('amount_minor_units'),
(string) $request->input('currency'),
);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
$this->service->charge($orderId, $email, $price);
return response()->json(['ok' => true]);
}
charge(OrderId, Email, Money) cannot be called with anything else. The validation happened once, in the constructors. The error messages name the rule that failed. The service method is shorter, the queue worker that also calls charge does not need its own copy of the checks, and the CLI command that backfills orders cannot accidentally pass a malformed input — the constructor refuses.
The defensive code at every other layer disappears. The controller is the only place that handles the InvalidArgumentException. Past that point, the types do the work.
What this costs and what it pays
The cost is real. OrderId is twenty lines you did not have before. Money is sixty. Email is thirty. Multiply by every identity and unit of measure in your domain and the codebase grows by hundreds of lines. A junior engineer counting lines as productivity will reach for raw strings every time.
The trade pays back in three places.
Code review is the first place. A reviewer reading refund(OrderId $orderId, CustomerId $customerId) does not have to guess the argument order — the types name the intent. A reviewer reading Money $price knows the value parsed, the currency is ISO, the amount is non-negative integer minor units. The cognitive load of reading the function drops because the signature has done the work.
Tests are the second. A test for Order::place(...) does not need to construct fixtures for invalid amounts and bad emails; the types refuse those inputs at construction. The test surface shrinks to "the rules the value object does not own" — typically state machine transitions and cross-aggregate constraints. Smaller test surface, faster suites, fewer flakes.
Framework migration is the third. The three classes import nothing from Laravel, Symfony, Doctrine, or any of their dependencies. When the team eventually rewrites the persistence layer or the HTTP layer, Money moves over untouched. The domain survives the framework. That is the point of the whole exercise, and the value object is the smallest, cheapest unit of evidence that it works.
PHP 8.3 made these classes cheap to write. The old objections were cost objections. They no longer apply.
If this was useful
The value objects above are the smallest unit of the architecture in Decoupled PHP. The book takes the same discipline — invariants in the type, framework at the edge — and walks an entire production service through it: aggregates, ports, use cases, inbound and outbound adapters, transactions that don't leak the database, and the strangler migration playbook for legacy Laravel and Symfony codebases that started without any of this.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)