← Back to articles
PHP Architecture & Design Patterns

Value Objects in PHP: Stop Writing Primitive-Obsessed Code

Learn how Value Objects eliminate primitive obsession in PHP, with real examples of Email and Money classes that bring type safety, validation, and clarity to your domain code.

✦
Featured article ↗

Value Objects in PHP: Stop Writing Primitive-Obsessed Code

Look at almost any PHP codebase and you'll find the same pattern: emails stored as strings, money stored as floats, IDs stored as integers, phone numbers stored as strings. This is called primitive obsession — the overuse of primitive types to represent domain concepts. It's one of the most common and most damaging anti-patterns in modern PHP development.

Value Objects are the cure. They bring type safety, validation, and clarity to your code — and once you start using them, you'll wonder how you ever lived without them.

What is a Value Object?

A Value Object (VO) is a small, immutable object that represents a concept from your domain. Unlike an Entity, a Value Object has no identity — it is defined entirely by its values. Two Value Objects with the same values are considered equal.

Classic examples include:

  • Email — instead of a raw string
  • Money — instead of a float plus a currency string
  • UserId — instead of an integer
  • PhoneNumber — instead of a string
  • DateRange — instead of two DateTime objects
  • Address — instead of an array of strings
  • Password — instead of a plain string

The Problem with Primitives

Consider this typical code:

<?php function registerUser(string $email, float $balance): void { // Is $email valid? Who knows. // Is $balance in USD, EUR, or BTC? No idea. // Can $balance be negative? Maybe. }

Every primitive value carries hidden assumptions. The type system tells you nothing about:

  • Whether the value is valid
  • What format it's in
  • What operations are allowed
  • What units or currency it represents
  • Whether it can be changed or reused safely

The result? Validation logic scattered everywhere, bugs from mismatched formats, and code that's difficult to reason about.

Your First Value Object: Email

Let's replace a primitive string with a proper Value Object:

<?php declare(strict_types=1); final class Email { private function __construct( private readonly string $value, ) { } public static function fromString(string $value): self { $normalized = mb_strtolower(trim($value)); if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException( sprintf('"%s" is not a valid email address.', $value) ); } return new self($normalized); } public function toString(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } public function __toString(): string { return $this->value; } }

Now the type system protects you. If you have an Email object, you know it's valid. No more scattered validation checks.

Using the Value Object

<?php final class User { public function __construct( public readonly UserId $id, public readonly Email $email, public readonly string $name, ) { } } // Now the constructor enforces validity: $user = new User( id: UserId::fromInt(42), email: Email::fromString('John@Example.com'), name: 'John Doe', ); echo $user->email->toString(); // "john@example.com" (normalized)

A More Complex Example: Money

Money is the classic case where primitives fail catastrophically. Floats lose precision, and mixing currencies is a silent bug waiting to happen.

<?php declare(strict_types=1); final class Money { private function __construct( private readonly int $amountInCents, private readonly string $currency, ) { if ($amountInCents < 0) { throw new InvalidArgumentException('Amount cannot be negative.'); } if (!preg_match('/^[A-Z]{3}$/', $currency)) { throw new InvalidArgumentException('Currency must be a 3-letter ISO code.'); } } public static function of(int $amountInCents, string $currency): self { return new self($amountInCents, $currency); } public static function fromFloat(float $amount, string $currency): self { return new self((int) round($amount * 100), $currency); } public function add(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents + $other->amountInCents, $this->currency, ); } public function subtract(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents - $other->amountInCents, $this->currency, ); } public function isGreaterThan(self $other): bool { $this->assertSameCurrency($other); return $this->amountInCents > $other->amountInCents; } public function amount(): int { return $this->amountInCents; } public function currency(): string { return $this->currency; } public function format(): string { return number_format($this->amountInCents / 100, 2) . ' ' . $this->currency; } public function equals(self $other): bool { return $this->amountInCents === $other->amountInCents && $this->currency === $other->currency; } private function assertSameCurrency(self $other): void { if ($this->currency !== $other->currency) { throw new InvalidArgumentException( sprintf('Cannot operate on %s and %s.', $this->currency, $other->currency) ); } } }

Now watch how clean the calling code becomes:

<?php $price = Money::fromFloat(19.99, 'USD'); $tax = Money::fromFloat(1.60, 'USD'); $total = $price->add($tax); echo $total->format(); // "21.59 USD" // This throws an exception instead of silently corrupting data: $price->add(Money::of(500, 'EUR'));

Key Characteristics of a Good Value Object

  • Immutable — once created, it never changes. Operations return new instances.
  • Self-validating — an instance cannot exist in an invalid state.
  • No identity — equality is based on values, not on a database ID.
  • Side-effect free — no database calls, no logging, no global state.
  • Small — represents one concept, not a whole aggregate.
  • Replaceable — the entire object can be swapped, not mutated field by field.

Value Object vs. DTO vs. Entity

It's important to distinguish these three patterns:

  • Value Object — immutable, defined by values, contains behavior related to those values (like add() on Money).
  • DTO — immutable or mutable, carries data between layers, contains no business logic.
  • Entity — has a unique identity that persists over time, even as its attributes change.

Where Value Objects Shine

  • Domain models — representing emails, money, IDs, dates, addresses.
  • Input validation — creating a VO from user input guarantees validity everywhere downstream.
  • Business rules — encapsulating logic such as currency matching or date range checks.
  • Testing — simpler, faster, and more focused tests without database dependencies.
  • Refactoring — when requirements change, you change the VO in one place.

Common Pitfalls to Avoid

  • Adding setters — this breaks immutability. If you need a different value, create a new instance.
  • Making them too big — a VO should represent one concept, not a whole aggregate root.
  • Adding persistence logic — keep database concerns in repositories, not in VOs.
  • Ignoring equality — always implement an equals() method.
  • Throwing generic exceptions — define domain-specific exceptions for clearer error handling.

A Practical Refactoring Path

You don't need to rewrite your entire application overnight. Start here:

  1. Identify a primitive field that causes bugs or confusion (emails, money, IDs).
  2. Create a Value Object for it with validation and useful methods.
  3. Replace the primitive in one class or one module.
  4. Let the compiler and your tests guide the rest.
  5. Repeat with the next obvious candidate.

Conclusion

Value Objects are one of the highest-leverage refactorings you can make in a PHP codebase. They eliminate primitive obsession, centralize validation, make invalid states impossible to represent, and turn your code into a self-documenting expression of your domain. The type system becomes an ally rather than a formality.

Start with a single Value Object — maybe Email or Money. Once you see how much clarity it brings to your code, you'll never go back to passing raw strings around.

Stop writing primitive-obsessed code. Let your types speak for your domain.

Technologies & topics

Article tags

No articles match these filters.

Have a project or an idea to discuss?

Let's talk ↗