Using Value Objects in PHP 8.1/8.2 to Improve Code Quality
The article explains how the Value Object pattern, combined with PHP 8.1/8.2 features like readonly properties and named arguments, can eliminate primitive‑type validation duplication, prevent parameter‑order mistakes, and ensure immutability, thereby making PHP code more robust, maintainable, and self‑documenting.
In the coding world, keeping code clean and robust is crucial. The Value Object pattern is a design pattern that can significantly improve code quality, making it more robust and maintainable.
This article explains how to implement the pattern using PHP 8.1 and PHP 8.2, leveraging the newest language features to add a bit of "syntactic sugar".
Common problems with primitive types
Simple data types lack built‑in validation, which can lead to unexpected values. For example, an age represented as an integer should not be negative, should not exceed a realistic maximum, and may need to be at least 18 in certain domains. Email strings must contain an @ symbol and a domain. Because validation logic is often duplicated throughout the code, inconsistencies can arise.
<code>function logic1(int $age): void {
($age >= 18) or throw InvalidAge::adultRequired($age);
// Do stuff
}
function logic2(int $age): void {
($age >= 0) or throw InvalidAge::lessThanZero($age);
($age <= 120) or throw InvalidAge::matusalem($age);
// Do stuff
}
</code>Using a value object solves these problems by encapsulating validation and ensuring data consistency.
<code>readonly final class Age {
public function __construct(public int $value) {
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
}
}
</code> <code>function logic1(Age $age): void {
// Do stuff
}
function logic2(Age $age): void {
// Do stuff
}
</code>When an Age instance exists, it is guaranteed to be valid everywhere in the code without repeated checks.
2. Parameter order confusion
When functions accept parameters of similar types, it is easy to swap their order, causing subtle bugs. For example, logic1() expects $name then $surname , while logic2() expects the opposite.
<code>function logic1(string $name, string $surname): void {
// Logic error, $name is switched with $surname, unintentionally
logic2($name, $surname);
}
function logic2(string $surname, string $name): void {
// Do stuff
}
</code>PHP 8.0 introduced named arguments, which can eliminate this class of errors.
<code>function logic1(string $name, string $surname): void {
logic2(name: $name, surname: $surname);
}
</code>3. Unexpected modification
Passing primitive values by reference can unintentionally modify the original variable.
<code>function logic1(int &$age): void {
if ($age = 42) { // BUG: assignment instead of comparison
echo "That's the answer\n";
}
echo "Your age is $age\n"; // Always prints 42
}
</code>Using immutable value objects prevents such side effects.
<code>final readonly class Age {
public function __construct(public int $value) {
// validation
}
}
function logic1(Age $age): void {
if ($age->value = 42) { // will trigger a runtime error because property is readonly
echo "That's the answer\n";
}
echo "Your age is {$age->value}\n"; // prints original value
}
</code>Classes as Types
Value objects treat classes as types, encapsulating data and behavior, which gives developers better control and validation over the data.
Key Characteristics of Value Objects
1. Immutability
Once created, a value object's internal data must not change. Prior to PHP 8.1 this was achieved with private properties and getters only. PHP 8.1 introduced the readonly keyword, simplifying immutable property declarations.
<code>class Age // PHP 8.1
{
public function __construct(public readonly int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
}
}
</code> <code>class Age // PHP < 8.1
{
private int $value;
public function __construct(int $value)
{
($value >= 18) or throw InvalidAge::adultRequired($value);
($value <= 120) or throw InvalidAge::matusalem($value);
$this->value = $value;
}
public function value(): int { return $this->value; }
}
</code>2. Comparability
Value objects should be easy to compare for equality.
<code>// Money
public function equals(Money $money): bool {
return $this->amount === $money->amount && $this->currency === $money->currency;
}
$thousandYen = new Money(1000, Currency::YEN);
$thousandEuro = new Money(1000, Currency::EURO);
$thousandYen->equals($thousandEuro); // false
</code>3. Consistent Valid State
Validation should be performed inside the constructor so that an instance is always in a valid state. When using deserialization libraries, a validate method can be called after construction (e.g., via a #[PostLoad] attribute).
<code>public function __construct(public string $value) {
$this->validate();
}
private function validate(): void {
// perform specific validation
}
</code>4. Debuggability
Implementing __toString() for simple value objects or toArray() for complex ones provides easy debugging output.
<code>final readonly class Name {
public function __construct(public string $value) {}
public function __toString(): string { return $this->value; }
}
final readonly class Person {
public function __construct(public Name $name, public Surname $surname) {}
public function __toString(): string { return "{$this->name} {$this->surname}"; }
public function toArray(): array { return ['name' => (string)$this->name, 'surname' => (string)$this->surname]; }
}
</code>Using the Value Object pattern in PHP 8.2 can dramatically improve code quality, making applications more robust, maintainable, and less error‑prone.
php中文网 Courses
php中文网's platform for the latest courses and technical articles, helping PHP learners advance quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.