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:
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:
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
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.
Now watch how clean the calling code becomes:
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:
- Identify a primitive field that causes bugs or confusion (emails, money, IDs).
- Create a Value Object for it with validation and useful methods.
- Replace the primitive in one class or one module.
- Let the compiler and your tests guide the rest.
- 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.