In the article about where a business rule should live I wrote that an important rule should live close to the state it protects.
I had a very similar experience with Value Objects. At first, almost everything looked like a plain string: product code, email, version, external identifier.
The problem was not the string type itself. It appeared when rules accumulated around the value: normalize it, validate it, compare it correctly, or prevent two semantically different strings from being mixed up.
That is when a primitive string stopped being enough.
A Plain String Is Not a Problem by Itself
final class Product{ public function __construct( private string $name, private string $description, ) { }}If a value is only stored and displayed, a separate class may add no useful value.
DDD does not mean creating a class for every field. I wait for a signal that the value has started carrying more responsibility than simply "this is text."
First Signal: the Value Has Its Own Normalization
$email = strtolower( trim($email));At first, this normalization looks so small that a dedicated type feels unnecessary.
Then the same trim() and strtolower() appear in a Form Request, import, service, and tests.
At that point, one object that always creates the value in the same form becomes more useful.
Email Becomes a Useful Value Object When Its Rules Must Be Consistent Everywhere
final readonly class Email{ private function __construct( public string $value, ) { } public static function fromString( string $value, ): self { $value = strtolower( trim($value) ); if ( filter_var(Code that receives an Email no longer needs to ask whether the value was normalized.
The existence of the object means the basic validation has already succeeded.
For me, this is one of the main benefits of a Value Object: it makes code assumptions explicit.
ProductCode Adds Meaning That a String Does Not Carry
final readonly class ProductCode{ public function __construct( public string $value, ) { if ( ! preg_match( '/^[A-Z0-9][A-Z0-9_-]{2,31}$/', $value, ) ) { throw new InvalidArgumentException( 'Invalid product code.', ); } }string $code says very little about the parameter meaning.
ProductCode $code carries that meaning directly in the signature. It also prevents creation of a ProductCode that violates its own rules.
A Dedicated Type Prevents Primitive Mixups
public function assign( string $productCode, string $externalIdentifier,): void { // Both are strings.}public function assign( ProductCode $productCode, ExternalIdentifier $externalIdentifier,): void { // Their meaning is explicit.}When a method has several string arguments, correct ordering often lives in the developer's head.
When important values have their own types, the code carries part of that safety.
Money Shows Why One Number Is Often Not Enough
final readonly class Money{ public function __construct( public int $minorUnits, public string $currency, ) { if ($minorUnits < 0) { throw new InvalidArgumentException( 'Amount cannot be negative.', ); } if (strlen($currency) !== 3) { throw new InvalidArgumentException( 'Invalid currency.', );1000 alone does not explain the currency or unit.
Money combines the amount with its currency and operation rules. In this case, the Value Object is more than a wrapper around an int.
Version Is Useful When Comparison Has Domain Meaning
final readonly class Version{ public function __construct( public string $value, ) { if ( ! preg_match( '/^\d+\.\d+\.\d+$/', $value, ) ) { throw new InvalidArgumentException( 'Invalid version.', ); } }A version can technically be stored as a string, but ordinary string comparison does not express the required behavior.
When comparison rules belong to the value itself, a dedicated type makes the code more expressive.
A Value Object Is Defined by Its Value, Not by Its Own Identity
$emailA = Email::fromString( 'USER@example.com',); $emailB = Email::fromString( 'user@example.com',); // Same normalized value.// They represent the same email.An entity matters because of which specific thing it is. A Value Object matters because of the value it represents.
Two Email objects with the same normalized value mean the same thing to the domain. That is why I prefer such objects to be immutable.
Immutability Makes a Value Object Simpler
If an Email has already passed validation, I do not want someone to later do $email->value = "invalid".
readonly fits the nature of a Value Object well. If the value changes, a new object is created and passes the construction rules again.
A Value Object Keeps Small Rules Out of Generic Services
In the article about Application Service and Domain Service I wrote that Service should not become a home for all logic.
ProductCode normalization or Version comparison do not need a separate SomethingService when those behaviors naturally belong to the values themselves.
A Form Request and a Value Object Can Validate Different Things
Form Request: - field is present- input is a string- maximum request length Value Object: - domain format is valid- normalization is applied- value can exist in the domainI do not use a Value Object as a replacement for all HTTP request validation.
A Form Request validates input shape, while the Value Object guarantees that a constructed value follows domain rules.
A Value Object Can Still Be Stored as a Plain Scalar
Domain: ProductCode objectEmail objectVersion object Database: VARCHARVARCHARVARCHARA Value Object does not mean the database must store a serialized PHP object.
ProductCode can remain a VARCHAR. At the persistence boundary, the value is simply converted between a scalar and a Value Object.
In Laravel, a Cast or Mapper Can Hide the Conversion
protected function casts(): array{ return [ 'product_code' => ProductCodeCast::class, ];}In a specific project, this can be an Eloquent cast, repository mapper, or explicit conversion.
What matters more to me is keeping Value Object rules from spreading back into controllers and services.
I Do Not Wrap Every Primitive in a Class
Otherwise it is easy to end up with dozens of types that only store one field and have no rules.
That code may look very "DDD" while becoming harder without real benefit.
I create a Value Object when it removes repetition, protects valid values, adds important meaning, or moves natural behavior closer to the concept itself.
In AVN Center, Dedicated Types Appeared Together with Rules
While working on AVN Center, I did not try to define every possible Value Object in advance.
They became visible when an ordinary value started carrying its own validation, comparison, or distinct meaning in the model.
This is similar to what I described in the article about domain boundaries in a growing system: the model becomes more precise as our understanding of the domain grows.
I intentionally use general examples such as ProductCode, Email, Money, Version, and Identifier rather than internal future-product types.
When I Consider a Value Object
I ask a few simple questions:
- Does this value have validation rules of its own?
- Must it always be normalized in the same way?
- Does it have its own comparison or operation rules?
- Do two identical primitive types carry different domain meanings?
- Is the same logic around the value repeated in several places?
- Would the type in a method signature make the code clearer without a comment?
- Can valid state be guaranteed at construction time?
A Value Object Appears When a Value Stops Being Just a Value
For me, a Value Object is not mandatory in every DDD project and definitely not necessary for every field.
I use it when a primitive type starts hiding important assumptions.
ProductCode has a format. Email has normalization and validation. Money has currency and operation rules. Version has its own comparison. Identifier can prevent two different concepts from being mixed up.
When rules, meaning, or behavior appear together with a value, a dedicated type stops being an unnecessary wrapper and becomes part of the model.
A primitive type is enough while the value has no rules of its own. Once rules appear, a Value Object makes them visible in the model itself.



