DEV Community

Cover image for Value Object in PHP 8: Build your own type system
Christian Nastasi
Christian Nastasi

Posted on

Value Object in PHP 8: Build your own type system

 Table of content

Introduction

In our previous articles, we learned:

It's time to mix all these concepts together and apply them in a practical example.

This will allow us to create a custom-type system tailored to our application, extending and improving the native one.

The Practical Example

Let's consider a simple yet meaningful example: a library management system. We need to design the data structures to effectively represent that domain. There's no single way to do so; it depends on the specific problem we need to solve.

Analysis

Before writing any code, we should understand what we need to build. There is no greater waste than building the wrong thing or, worse, something useless. This preliminary phase sets the stage for a successful implementation.

An old but gold tool that proved itself useful despite passing years is certainly the UML Class Diagram.

This classic tool helps map out the relationships and hierarchies between different entities within your system. By visually representing how various elements interact, you gain a clear perspective on the structure of your data.

The Book entity

Let's start with the entities. It is easy to assume that the main entity is a book, written by one or more authors.

Entities

ISBN

In the real world, a book is identified by an ISBN, which stands for "International Standard Book Number." For the sake of simplicity, we will use a simplified version that doesn't follow the actual rules but is meaningful for the exercise.

Value Object - ISBN

Title

A book's title should be at least 2 characters long (the shortest title I can think of is IT, by Stephen King) and shorter than 255 characters (an arbitrary but good enough limit).

Value Object - Title

Description

A book might have a description, but it is not mandatory. As we did for the title, we set an upper bound size limit to prevent problems with very long strings.

Value Object - Description

Publish Year

PublishYear is an interesting one. Of course, it should be represented by an integer, but it has several limitations given by the domain itself. I believe it is reasonable to think that a book cannot have been published before the advent of printing, thus not before the first printed book: the Gutenberg bible, printed in 1455. It is equally reasonable to think that the publication date cannot be in the future, so it must be less than or equal to the current year.

Value Object - PublishYear

Authors

The property authors is a collection of Author, which is an entity itself.
An Author possesses an ID, which could be an integer value or, better, a UUID / ULID.

I personally prefer using a self-generated identifier over a progressive one. When we use autoincremented integers, we have to check which ID was last inserted each time, hoping that no one else requests the same ID and causes a collision. This creates a strong dependency on the persistence layer, which is better to avoid.

Author ID

AuthorId also has a new static method that generates a new, valid Author ID.

Value Object - AuthorID

Name

Things are a little more complex for the name property: instead of a simple Value Object, we've got a Composite Value Object.

Value Object - Name

Build your own type system

So far, everything is going well. The data structures appear to accurately depict our domain, and our data remains consistent due to strict validation of our entities and value objects.

However, as many of you have likely noticed, the value objects we defined have numerous similarities, leading to code duplication when recurring patterns emerge.

So, how can we improve? In short, we can develop our base type system, encapsulate the language primitives, and then build our Value Objects and Entities using them.

Looking at the data structures described in the example, we can easily recognize 3 different kinds of value objects:

  • Integers
    • Publish Year
  • Strings
    • ISBN
    • Book title
    • Book description
    • First, last and middle name
  • Identifiers
    • Author ID

Same primitive types but different validation rules. So, how to design them to manage those different rules? The answer is "abstraction".

Whole Picture

But let's see how to build those kinds of value objects in practice with real code.

Integers

Let's start writing a Value Object for integer values, which is one of the simplest primitive value objects.

To ensure immutability, we'll add the readonly attribute to the class.

readonly class Integer {
}
Enter fullscreen mode Exit fullscreen mode

Please memorize the following text:

We shouldn't define it as abstract because sometimes we just need a generic integer and nothing more, so we don't have to extend the Integer class for every integer property.

For the constructor, we'll use the final keyword to ensure that no one can change the behavior. Then, we put the validation outside the constructor, with a protected visibility. This allows us to override the "validate" method in case we have special cases to handle that go outside the norm. The value will be public, and there will be no need for getters (because the property is inherited readonly).

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

protected function validate():void
{
    /* ... */
}
Enter fullscreen mode Exit fullscreen mode

Let's explore the validate method. For validation, we only need to check if the value is within a certain range. This range should be parametric because it may vary between data. For example, for an Age ValueObject, the range could be between 0 and 140.

To do so, we will use two constants, MIN and MAX.

protected const int MIN = PHP_INT_MIN;

protected const int MAX = PHP_INT_MAX;
Enter fullscreen mode Exit fullscreen mode

We could set both of them as null, but I don't like to deal with nulls when it's not necessary.

Then, inside the validate method, the check is simple.

protected function validate(): void
{
    ($this->value >= static::MIN) or throw InvalidInteger::greaterOrEqualThan(static::MIN, $this->value);
    ($this->value <= static::MAX) or throw InvalidInteger::lessOrEqualThan(static::MAX, $this->value);
}
Enter fullscreen mode Exit fullscreen mode

Remember: always use the static keyword when accessing the constants. If we used the self keyword, the trick doesn't work. If you want to understand why, take a look at the late static binding documentation.

To completeness, let's add also the __toString and the equals methods.

public function __toString(): string
{
    return (string)$this->value;
}

public function equals(Integer $integer): bool
{
    return $integer->value === $this->value;
}
Enter fullscreen mode Exit fullscreen mode

And done. We have all the building blocks that we need.

Now, what if we need to extend and put some limits on our integers? Let's take, for example, the a value object for positive integers (that means value always >= 0).

readonly class PositiveInteger extends Integer
{
    protected const int MIN = 0;
}
Enter fullscreen mode Exit fullscreen mode

That's it. If you have a static range, you just redefine the MIN and MAX constants and nothing more.

But what if we have a dynamic range? If you remember, we have the "PublishYear" Value Object, which has an upper limit based on the current year. If we try to do something like this, the interpreter will raise an error.

// Expression is not allowed as class constant value
protected const int MAX = Carbon::now()->year;
Enter fullscreen mode Exit fullscreen mode

To solve this, we have to override the validate method.

readonly class PublishYear extends Integer
{
    protected const int MIN = 1455;

    protected function validate(): void
    {
        // Don't forget to call the parent method
        parent::validate();

        $currentYear = Carbon::now()->year;

        ($this->value <= $currentYear) or throw InvalidInteger::lessOrEqualThan($currentYear, $this->value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's refactor the PublishYear class using this design.

First Refactor

String Values

We need a little more complexity to represent string values. Most string validation is based on length, but sometimes, this is not enough. Take the simplified ISBN we used before as an example: it has a specific format and a specific length. A practical way to check that kind of value is using a regex pattern.

Unfortunately, we cannot call a class String in PHP, so we must find alternatives. The best solutions I've found in those years are StringValue or Text.

Similar to what we did for the Integers, we will have 2 protected (and optional) fields for the minimum and maximum length validation. If not specified, the check will not be performed.

We will also have a regex field, which is also optional.

Then, we can build a hierarchy that specialises in the kind of string values that we need.

Value Object - String Values

But is it really necessary to create a Value Object for every field, then? The answer is no; it depends. Specialize the generic value objects only if the children's class needs something more; for example, adding methods/fields or equality must be strict (based on class and not only on the value); otherwise, you can use the generic one.

Value Object - Generic

The big picture

Following this approach, and with a domain bigger than the one we are using as an example, it's easy to wrap all primitives, and what comes out is something similar to this.

Whole Picture

Time to code

Now that we have an exact picture in mind, we can start to code.

There are a lot of people who think analysis is useless and is not worth the time spent on it. After 2 decades of developing stuff, I can affirm without uncertainty that there's no more important thing than analysis, and there's not much time lost as the time spent coding the wrong thing or worse, a feature that will never be used.
We developers like thinking a lot about all the possibilities and what-ifs, but please stop doing that. YAGNI is the way (You Aint Gonna Need It).

Integers

Implementing an Integer Value Object is pretty straightforward. The 2 constants MIN and MAX give the necessary freedom to restrict the range and keep simplicity in the extension.

readonly class Integer
{
    public const MIN = PHP_INT_MIN;
    public const MAX = PHP_INT_MAX;

    final protected function __construct(public int $value)
    {
        $this->validate();
    }

    protected function validate(): void
    {
        ($this->value >= static::MIN) or throw InvalidInteger::moreOrEqualsThan(static::MIN, $this->value);
        ($this->value <= static::MAX) or throw InvalidInteger::lessOrEqualsThan(static::MAX, $this->value);
    }

    public static function create(int $value): static
    {
        return new static($value);
    }

    public function equals(Integer $integer): bool
    {
        return $this->value === $integer->value;
    }

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

For example, if we want to extend it and have, for example, a PositiveInteger Value Object, we need to do this:

readonly class PositiveInteger extends Integer
{
    public const MIN = 1;
}
Enter fullscreen mode Exit fullscreen mode

Conclusion:

  • Reinforcing the value of a custom type system using Value Objects
  • Encouraging developers to explore and experiment with domain-specific types in PHP 8
  • Emphasizing how this approach contributes to a more expressive and reliable codebase

Top comments (1)

Collapse
 
xwero profile image
david duymelinck

Why would you need a boolean value object, there are only two options?

I don't think the ISBN value object should be a child of the StringValue class. The regex should contain the start and stop symbol, so min and max is useless. And the regex is useless for most of the other stringValue children.

Because the naming is so generic, it is very tempting to move the value objects to a shared domain. And because people are lazy or in a time crush the chance the object gets domain specific features will become more likely.
I rather have objects with duplicate code, with their domain specific name, then there is less or no temptation.
It is the choice between not buying the candy you like so much, or buying it and storing it at home in a difficult place.