01 · THE IDEAREAD → TRY → LEARN
Why value objects?
A plain string or integer carries no promise. A variable named $percent could hold -5, 4000 or "banana", and every function that receives it has to wonder. A value object is a small class that wraps one value (or a few related values), checks it once in the constructor, and from then on guarantees it is valid. Anywhere you receive the object, you can trust it.
Two habits define a good value object:
- It validates on creation. An invalid object can never exist.
- It is immutable. Once built, it never changes. A different value means a different object.
The class basics from the manual apply directly: a class has properties and methods, and new creates an instance. A constructor may throw an exception, in which case no usable object is produced.
A first value object
PHP offers constructor promotion and readonly properties, which make this style short. A readonly property can be set only once, from inside the class. Declaring the class final stops subclasses from weakening the rules.
final class Percent
{
public function __construct(public readonly int $value)
{
if ($value < 0 || $value > 100) {
throw new InvalidArgumentException('Percent must be 0-100');
}
}
public function label(): string
{
return $this->value . '%';
}
}
echo (new Percent(45))->label(), PHP_EOL;45%
The label() method gives a predictable scalar representation. Callers do not build the string themselves, so the format lives in one place.
Rejecting bad input
The constructor is the gatekeeper. When the check fails it throws, and the caller decides how to react.
final class Percent
{
public function __construct(public readonly int $value)
{
if ($value < 0 || $value > 100) {
throw new InvalidArgumentException('Percent must be 0-100');
}
}
}
try {
new Percent(120);
} catch (InvalidArgumentException $e) {
echo $e->getMessage(), PHP_EOL;
}Percent must be 0-100
Boundaries matter here: 0 and 100 are valid, -1 and 101 are not. Always test both sides of each edge.
Changing a value without mutation
Because properties are readonly, you do not modify an object. You return a new one, often from a method named withSomething(). The original is untouched, so any code still holding it is safe.
final class Percent
{
public function __construct(public readonly int $value)
{
if ($value < 0 || $value > 100) {
throw new InvalidArgumentException('Percent must be 0-100');
}
}
public function withValue(int $value): self
{
return new self($value);
}
}
$a = new Percent(10);
$b = $a->withValue(20);
echo json_encode([$a->value, $b->value]), PHP_EOL;[10,20]
Notice that withValue() goes through the constructor again, so the new object is validated by the same rules.
Named constructors and normalization
Sometimes invalid input is expected, for example text typed by a user. Throwing for every typo may be heavy. A common pattern is a private constructor plus a static factory such as tryFrom() that returns null on failure. The factory also normalizes: it trims, lowercases or expands the input so equal values always end up with the same representation. That is what makes two objects comparable by their scalar form.
Exercise contract
You will model a hex color. Write the function normalizeColor(string $input): ?string. Inside it, use a small value object (a class you define in the same file) so the rules live in one place. The contract:
- Trim surrounding whitespace and lowercase the text.
- Allow at most one leading
#; remove it. - The remaining text must be exactly 3 or 6 hexadecimal digits (
0-9,a-f). - A 3-digit color expands by doubling each digit, so
fa0becomesffaa00. - Return the result with a leading
#, for example#ffaa00. - Return
nullfor anything invalid, including the empty string and a doubled##.
The tests check the returned JSON value, so return null rather than printing a message. Helpful built-ins are trim(), strtolower(), str_starts_with(), substr(), strlen() and ctype_xdigit().
Recap
- A value object validates once and is then trustworthy everywhere.
- Use
readonlyproperties andfinalclasses to keep it immutable. - Expose one predictable scalar form such as
label()or avalueproperty. - Produce changed copies instead of mutating, and normalize input so equal values look identical.
Functions · Arrays · Types
- Validate a value and expose a predictable scalar representation
- Implement and test a deterministic PHP 8.4 function