DEV Community

Cover image for Value Objects in PHP 8: Building a better code
Christian Nastasi
Christian Nastasi

Posted on • Edited on

Value Objects in PHP 8: Building a better code

Table of contents

Introduction

In the world of coding, keeping our code clean and strong is a big deal.

The Value Object pattern has the potential to significantly enhance your code quality, making it more robust and maintainable.

In this article, I will explain how to implement the pattern and how this will add some "sugar" to your code, using the latest features introduced by PHP 8.1 and PHP 8.2.

Problems with primitives

Before we jump into Value Objects, let's talk about the issues with basic data types. Here are three common problems:

1. Invalid values

Simple data types don't have built-in checks to ensure the data is valid. This can lead to unexpected issues in our code.

An age could be represented by an integer, but of course, cannot be negatives or more than 120 (more or less). Maybe in our domain makes sense that age should be more or equal than 18 years old.

An email could be represented by a string, but it's not. It is a subset of all possible strings, and several checks are necessary to ensure is valid.

We possibly have a lot of different places in our code that use those values, but we can trust no one, and we had to ensure that our data is consistent. Consequently, we must validate the data passed as an argument every time.

This leads to a validation logic duplication issue. Each of these duplicated logic could potentially differ from each other, leading to inconsistencies.

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
}
Enter fullscreen mode Exit fullscreen mode

Using Value Objects should do the trick. It will simplify a lot your code and also ensure consistency in your data.

readonly final class Age 
{
     public function __construct(public int $value)
     {
          ($value >= 18) or throw InvalidAge::adultRequired($value);
          ($value <= 120) or throw InvalidAge::matusalem($value);
     }
}
Enter fullscreen mode Exit fullscreen mode
function logic1(Age $age): void 
{
    // Do stuff
}

function logic2(Age $age): void 
{
    // Do stuff
}
Enter fullscreen mode Exit fullscreen mode

In this way, you are sure that if an instance of Age exists, it is valid and consistent, everywhere in your code, without checking every time.

2. Mixing Up Arguments

When dealing with functions that take similar types of data, it's easy to mix up the order of arguments. This can cause hard-to-spot bugs.

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
}
Enter fullscreen mode Exit fullscreen mode

This type of bug is particularly tricky, and currently, there isn't a built-in check that could help us prevent it.

There are only two ways to solve this issue:

  • Using Value Objects: The interpreter itself or static analysis tools can easily recognize when there's a type mismatch.
function logic1(Name $name, Surname $surname): void 
{
    // Static analysis error
    // Expected Surname, found Name
    logic2($name, $surname);
}

function logic2(Surname $surname, Name $name): void {
    // Do stuff
}
Enter fullscreen mode Exit fullscreen mode
  • Using named arguments: Introduced in PHP 8.0. In this way, order doesn't matter.
function logic1(string $name, string $surname): void 
{
    logic2(name: $name, surname: $surname);
}
Enter fullscreen mode Exit fullscreen mode

3. Accidental Changes

Simple data types can be changed without us realizing it. When we pass them to a function, that function might accidentally modify the original data.

function logic1(int &$age): void 
{
    if ($age = 42) { // BUGS alert
        echo "That's the answer\n";
    }

    echo "Your age is $age\n"; // It will print always 42
}
Enter fullscreen mode Exit fullscreen mode

Given that is rare to pass arguments by reference using &, this example has got not one but two bugs:

  • $age = 42 is an assignment, not a comparison. It overwrites the current value without us noticing it and everything that uses that value after will be affected by this bug.
  • Changing a value passed by reference means that the variable outside the call will be changed as well. This could be intentional, but sometimes it's not.

Using Value Objects will solve this issue because they grant immutability.

final readonly class Age
{
    public function __construct(public int $value)
    { // validation }
}

function logic1(Age $age): void 
{
    // Interpreter error
    // cannot write a readonly property
    if ($age->value = 42) { 
        echo "That's the answer\n";
    }

    echo "Your age is $age\n"; 
}
Enter fullscreen mode Exit fullscreen mode

Classes as Types

Value Objects fix these problems by treating classes as types. Unlike simple data types, Value Objects wrap their data in a class. This helps us control and validate our data better.

Key Qualities of Value Objects

To make the most of Value Objects, we need to focus on a few important things:

1. Can't Change It (Immutability)

Value Objects should stay the same once we create them. This helps us avoid unexpected changes. In the past, prior to PHP 8.1, that was achieved by having private/protected properties and getters only. Setters were prohibited.

If there is a reason why the inner data has to change, then a new instance will be created, without changing the current instance.

PHP 8.1 simplified a lot introducing readonly properties and property promotion.

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Prior to PHP 8.1


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; }
}
Enter fullscreen mode Exit fullscreen mode

The following example shows how to deal with changes in the value object.
As you can see, no instance property is changed. Instead, a new instance was created.

final readonly class Money // PHP 8.2
{
    public function __construct(
        public int $amount,
        public string $currency
    ) {
        ($amount > 0) or throw InvalidMoney::cannotBeZeroOrLess($amount);
    }

    public function sum (Money $money): Money 
    {
        if ($money->currency !== $this->currency) {
            throw InvalidMoney::cannotSumPearsWithApples($this->currency, $money->currency);
        }

        $newAmount = $this->amount + $money->amount;

        return new Money($newAmount, $this->currency);
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Easy to Compare (Comparability)

Making Value Objects comparable means we can easily check if they are the same or different. This comes in handy when sorting or searching.

// Money
public function equals(Money $money): bool 
{
    return $this->amount === $money->amount
        && $this->currency === $money->currency;   
}

// code
$thousandYen = new Money(1000, Currency::YEN);
$thousandEuro = new Money(1000, Currency::EURO);

$thousandYen->equals($thousandEuro); // false
Enter fullscreen mode Exit fullscreen mode

Because of course, ¥1000 is not the same as €1000.

3. Always Good Data (Consistency)

A Value Object should always represent something valid. By checking and validating inside the object, we make sure it's always in good shape.

This means that the validation should be done inside the constructor to ensure that if the instance exists, then it's valid. Always!

WARNING!
Be careful with deserializers: sometimes they build the object without calling the constructor.

An interesting approach could be having a validate method, called inside the constructor and after one of this kind of deserializing process.

public function __construct(public string $value)
{
    $this->validate();
}

private function validate(): void 
{
    // Do the validation stuff
}
Enter fullscreen mode Exit fullscreen mode

For example, using Serde PHP deserializer, you must add the attribute #[PostLoad] to make it call after the instantiation.

#[PostLoad]
private function validate(): void 
{
    // Do the validation stuff
}
Enter fullscreen mode Exit fullscreen mode

This is because most of them use this reflection method under the hood

public ReflectionClass::newInstanceWithoutConstructor(): object
Enter fullscreen mode Exit fullscreen mode

4. Easy to debug (Debuggability)

It's a good practice to equip Value Objects with an easy way to debug themself. In the case of a simple value object, a __toString method shall do the job. Otherwise, in the case of a composite value object (with a lot of properties and inner other value objects) a toArray is suggested. Or else, a serializer might be used.

final readonly class Name
{ 
    public function __construct(public string $value) {}

    public function __toString(): string 
    { 
        return $this->value; 
    }
}
Enter fullscreen mode Exit fullscreen mode
final readonly class Surname
{ 
    public function __construct(public string $value) {}

    public function __toString(): string 
    { 
        return $this->value; 
    }
}
Enter fullscreen mode Exit fullscreen mode
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
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Wrapping It Up

In conclusion, embracing the Value Object pattern in PHP 8.2 significantly enhances code quality, making it more robust and maintainable. By treating classes as types, focusing on immutability and always-valid data, and leveraging features introduced in PHP 8.1 and 8.2, developers can create more stable and resilient applications. Adding Value Objects to your coding toolkit not only improves the look of your code but also simplifies the development process and sets the stage for a more scalable and error-resistant codebase.

Top comments (6)

Collapse
 
ianrodrigues profile image
Ian Rodrigues

Hi @cnastasi, very nice writing. I'm eager to read new articles from you :)

Collapse
 
jovialcore profile image
Chidiebere Chukwudi • Edited

Thanks for creating such article. Super helpful! However, the "order of argument" issue can be avoided using named parameters

Collapse
 
cnastasi profile image
Christian Nastasi

Thanks, Chidiebere!

I hope you will find helpful the other articles of the same series as well... Soon I will publish part 3

Collapse
 
webdevpassion profile image
Denis Aklikli • Edited

Thank you for this post! The concept of ValueObjects is quite useful, especially the fact that we can use them to enhance PHP's native type system by leveraging classes. However, and this may be a basic question, how do ValueObject classes affect performance compared to primitive types? Of course, the impact on memory usage depends on how we build our ValueObject classes, but is there a noticeable difference in terms of performance?

Collapse
 
cnastasi profile image
Christian Nastasi

Thank you for the comment.

Certainly, there's an overhead associated with the instantiation of objects, speaking in absolute terms.

However, drawing from my experience, this overhead typically translates to mere fractions of seconds. Importantly, the most time-consuming operations often center around I/O activities rather than in-memory processes (within certain limitations, of course).

This is precisely why, with a well-crafted implementation of the Repository Pattern complemented by a caching layer, it becomes feasible to enhance the overall execution efficiency.

Collapse
 
Sloan, the sloth mascot
Comment deleted