Value Objects in PHP: Why Primitives Like Strings and Floats Aren't Always Enough

A Value Object is a small, immutable class that wraps a primitive value — a string, a float, an integer — and gives it validation rules and behavior of its own. The problem it solves is called primitive obsession: using raw types everywhere for concepts that actually have rules attached to them, which means those rules end up duplicated, inconsistent, or simply forgotten in some corner of the codebase. This article walks through a real before/after refactor to show exactly what that looks like.

 

The Problem With Primitives

Two examples make this concrete: email addresses and money.


An email address is usually stored as a plain string. But an email has rules — it must be validated, normalized (lowercase, trimmed), and compared carefully. If every part of the codebase that touches an email re-implements that validation, you get inconsistency: one form validates it strictly, another barely checks for an @ sign, and a third assumes it's already valid because "it came from the database."


Money is worse, because it's usually stored as a float, and floats cannot represent decimal currency values exactly. This isn't a theoretical concern — 0.1 + 0.2 in PHP does not equal 0.3 due to how floating-point numbers are represented in binary. On financial data, this class of bug produces off-by-a-cent totals that are notoriously hard to track down, because the code looks correct.

 

Before: Primitives Everywhere

Here's what this typically looks like without Value Objects:

```php
class RegistrationController
{
   public function register(Request $request): Response
   {
       $email = strtolower(trim($request->input('email')));

       if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
           return new Response('Invalid email', 422);
       }

       User::create([
           'email' => $email,
           'balance' => 0.0,
       ]);

       return new Response('Registered', 201);
   }
}

class WalletController
{
   public function deposit(Request $request, int $userId): Response
   {
       $amount = (float) $request->input('amount');
       $user = User::find($userId);
       $user->balance = $user->balance + $amount;
       $user->save();

       return new Response('Deposited', 200);
   }
}
```

 

After: Introducing an Email Value Object

Validation now lives in exactly one place, and an invalid Email simply cannot exist:

```php
final class Email
{
   private string $value;

   public function __construct(string $value)
   {
       $normalized = strtolower(trim($value));

       if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
           throw new InvalidArgumentException("Invalid email: {$value}");
       }

       $this->value = $normalized;
   }

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

   public function equals(Email $other): bool
   {
       return $this->value === $other->value;
   }

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

class RegistrationController
{
   public function register(Request $request): Response
   {
       try {
           $email = new Email($request->input('email'));
       } catch (InvalidArgumentException $e) {
           return new Response('Invalid email', 422);
       }

       User::create([
           'email' => $email->value(),
           'balance' => Money::zero('EUR'),
       ]);

       return new Response('Registered', 201);
   }
}
```

 

Money as a Value Object: Avoiding Float Arithmetic Bugs

Storing cents as integers instead of a float eliminates the rounding bug class entirely:

```php
final class Money
{
   private int $cents;
   private string $currency;

   private function __construct(int $cents, string $currency)
   {
       $this->cents = $cents;
       $this->currency = $currency;
   }

   public static function fromCents(int $cents, string $currency): self
   {
       return new self($cents, $currency);
   }

   public static function zero(string $currency): self
   {
       return new self(0, $currency);
   }

   public function add(Money $other): self
   {
       if ($this->currency !== $other->currency) {
           throw new InvalidArgumentException('Currency mismatch');
       }

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

   public function toDisplayString(): string
   {
       return number_format($this->cents / 100, 2) . ' ' . $this->currency;
   }
}

class WalletController
{
   public function deposit(Request $request, int $userId): Response
   {
       $amount = Money::fromCents((int) $request->input('amount_cents'), 'EUR');
       $user = User::find($userId);
       $user->setBalance($user->balance()->add($amount));
       $user->save();

       return new Response('Deposited', 200);
   }
}
```

 

What Actually Changed

Three concrete improvements came out of this refactor. First, validation now happens in exactly one place — the Email constructor — so it's impossible to end up with an invalid, unvalidated email anywhere in the system, because an Email object simply cannot exist in an invalid state. Second, the money bug class disappears entirely: Money stores integer cents, so arithmetic is exact, and currency mismatches throw immediately instead of silently producing a wrong number. Third, both controllers got shorter and more readable, because the validation and business rules moved into the types themselves instead of being re-typed inline.

 

Where Value Objects Fit Into a Layered Architecture

Value Objects pair naturally with the patterns covered elsewhere in this series. repository typically returns and accepts entities built from Value Objects rather than raw arrays or primitives — so a repository method's signature becomes self-documenting: findByEmail(Email $email) tells you exactly what's expected, where findByEmail(string $email) leaves the validation rules implicit and unenforced. This is the same underlying idea covered in our overview of the core components of a modern PHP framework: pushing rules into the type system, rather than scattering them across controllers and services, is what keeps a codebase reliable as it grows.

 

When Value Objects Are Overkill

Not every primitive needs to become a class. A one-off internal counter or a value with genuinely no validation rules or behavior attached to it doesn't benefit from the wrapping. Value Objects earn their cost specifically where a primitive represents a real domain concept with rules (an email, a currency amount, a phone number, a percentage) — not for arbitrary scalar values passed around internally.

 

Frequently Asked Questions

Are Value Objects the same as DTOs (Data Transfer Objects)?
No. A DTO's job is moving data between layers, typically without behavior or validation. A Value Object's job is enforcing that a value is always valid and providing behavior (like Money::add()) — the two can look similar syntactically but serve different purposes.


Should Value Objects be immutable?
Yes, as a rule. Immutability is what makes them safe to pass around and compare without worrying about one part of the code unexpectedly mutating a value another part depends on — operations like Money::add() return a new instance rather than modifying the original.


Does this add too many small classes to a codebase?
It adds files, but each one is small, focused, and easy to test in isolation — a tradeoff that tends to pay for itself the first time a validation bug would otherwise have shipped to production.
 

Working on a PHP codebase where primitive obsession is causing real bugs? Get in touch.

Contact Pierre Miniggio →