DEV Community

Cover image for Value Objects in PHP 8: Advanced usage
Christian Nastasi
Christian Nastasi

Posted on • Updated on

Value Objects in PHP 8: Advanced usage

Table of contents

Introduction

In the previous article, we explored the power of Value Objects in improving code quality, system robustness, and minimizing the need for extensive validation. Now, let's dive deeper to enhance our understanding and usage of this crucial tool.

Different kinds of value objects

When dealing with Value Objects, it's beneficial to classify them into different types based on their complexity. In my experience, I've identified three main types:

  • Simple value objects
  • Complex value objects
  • Composite value objects

There could be a fourth, but it would essentially be a mix of these three types.

Simple Value Object

Simple Value Objects encapsulate a single value, often representing a primitive value or basic concept within your domain. These objects are ideal for straightforward attributes or measurements.

Let's take the Age Value Object introduced in the previous article as an example:

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

     public function validate(): void
     {
          ($this->value >= 18) 
              or throw InvalidAge::adultRequired($this->value);

          ($this->value <= 120) 
              or throw InvalidAge::matusalem($this->value);
     }

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

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

Enter fullscreen mode Exit fullscreen mode

In this example, Age is a Simple Value Object that represents a person's age. It encapsulates a single integer value and includes a validation mechanism to ensure the age falls within a reasonable range.
The __toString method allows easy conversion to a string, and the equals method compares two Age objects for equality.

When creating Simple Value Objects, consider the following guidelines:

  • Single Responsibility:
    Keep the Value Object focused on representing a single concept or attribute, often corresponding to a primitive value.

  • Immutability:
    Once created, a Simple Value Object should not be altered. Any changes should result in the creation of a new instance.

  • Validation:
    Include validation logic within the constructor to ensure the object is always in a valid state.

  • String Representation:
    Implement the __toString method for convenient string conversion when needed.

  • Equality Check:
    Provide an equals method to compare two instances for equality.

By adhering to these guidelines, you create Simple Value Objects that enhance the clarity, stability, and reliability of your code.

Complex Value Object

While Simple Value Objects encapsulate a single value, Complex Value Objects handle more intricate structures or multiple attributes, forming a richer representation within your domain. These objects are well-suited for modelling complex concepts or aggregations of data.

Consider the Coordinates Value Object:

readonly final class Coordinates 
{
     public function __construct(
          public float $latitude,
          public float $longitude
     )
     {
          $this->validate();
     }

     private function validate(): void
     {
          ($this->latitude >= -90 && $this->latitude <= 90) 
              or throw InvalidCoordinates::invalidLatitude($this->latitude);

          ($this->longitude >= -180 && $this->longitude <= 180) 
               or throw InvalidCoordinates::invalidLongitude($this->longitude);
     }

     public function __toString(): string 
     {
          return "Latitude: {$this->latitude}, Longitude: {$this->longitude}";
     }

     public function equals(Coordinates $coordinates): bool 
     {
          return $coordinates->latitude === $this->latitude
              && $coordinates->longitude === $this->longitude;
     }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the Coordinates Value Object represents geographic coordinates with latitude and longitude. The constructor ensures the object's validity, validating that latitude falls within the range [-90, 90] and longitude within [-180, 180]. The __toString method provides a readable string representation, and the equals method compares two Coordinates objects for equality.

When creating Complex Value Objects, consider the following guidelines:

  • Structured Representation:
    Model the object to reflect the complexity and structure of the corresponding domain concept.

  • Validation:
    Implement validation logic within the constructor to ensure the object is always in a valid state.

  • String Representation:
    Include a meaningful __toString method for better readability and debugging.

  • Equality Check:
    Provide an equals method to compare two instances for equality.

This approach allows you to create Complex Value Objects that effectively represent intricate concepts within your application, such as geographic coordinates in this case.

Although not obvious in this example, Complex Value Objects often require more detailed checks. It's not just about individual values but also how they relate to each other.

Consider this example:

readonly final class PriceRange
{
    public function __construct(
        public int $priceFrom,
        public int $priceTo
    ) {
        $this->validate();
    }

    private function validate(): void
    {
        ($this->priceTo >= 0) 
            or throw InvalidPriceRange::positivePriceTo($this->priceTo);

        ($this->priceFrom >= 0) 
            or throw InvalidPriceRange::positivePriceFrom($this->priceFrom);

        ($this->priceTo >= $this->priceFrom) 
            or throw InvalidPriceRange::endBeforeStart($this->priceFrom, $this->priceTo);
    }

    // ...(rest of the methods)
}
Enter fullscreen mode Exit fullscreen mode

In this case, even if each price is fine on its own, we need to ensure that the priceTo must come after or is the same as the priceFrom.

Composite Value Object

Composite Value Objects are powerful structures that combine multiple Simple or Complex Value Objects into a cohesive unit, representing more intricate concepts within your domain. This allows you to build rich and meaningful abstractions.

Let's illustrate this with an example using an Address Value Object:

readonly final class Address 
{
    public function __construct(
        public Street $street,
        public City $city,
        public PostalCode $postalCode
    ) {}

    public function __toString(): string 
    {
        return "{$this->street}, {$this->city}, {$this->postalCode}";
    }

    public function equals(Address $address): bool 
    {
        return $address->street->equals($this->street)
            && $address->city->equals($this->city)
            && $address->postalCode->equals($this->postalCode);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the Address Composite Value Object is composed of Street, City, and PostalCode. Each sub-object encapsulates a single value, and together they form a more comprehensive representation of an address.

The __toString method provides a readable string representation, and the equals method compares two Address objects for equality.

When creating Composite Value Objects, consider the following guidelines:

  • Composition:
    Assemble multiple Simple or Complex Value Objects to create a more intricate structure.

  • Abstraction:
    Represent complex concepts within your domain using a composite structure.

  • String Representation:
    Include a meaningful __toString method for better readability and debugging.

  • Equality Check:
    Provide an equals method to compare two instances for equality.

In many cases, validating a Composite Value Object is not necessary as its validity is already ensured by its components. However, just like in Complex Value Objects, there might be scenarios where logic demands validation across different properties of the object. In such cases, validation is, of course, required.

Factory methods & Private constructors

In the examples presented so far, we've explored relatively simple value objects. However, everyday development introduces challenges when value objects have internal representations differing from their externals.

To illustrate this challenge, let's consider the DateTime concept. The date "24th December 2023, 4:09:53 PM, Rome time zone" can be represented in various ways, such as seconds since January 1, 1970, or as an RFC3339 string.

Unlike languages like Java or C#, PHP lacks constructor overloading. Here, the factory method design pattern, employing one or more static methods, becomes invaluable for controlled object instantiation.

Let's take a closer look at this value object:

class DateTimeValueObject
{
    private DateTimeImmutable $dateTime;

    private function __construct(DateTimeImmutable $dateTime)
    {
        $this->dateTime = $dateTime;
    }

    // Factory method to create from timestamp
    public static function createFromTimestamp(int $timestamp): self
    {
        ($timestamp >= 0) or InvalidDateTime::invalidTimestamp($timestamp);

        $dateTime = new DateTimeImmutable();
        $dateTime = $dateTime->setTimestamp($timestamp);

        return new self($dateTime);
    }

    // Factory method to create from RFC3339 string
    public static function createFromRFC3339(string $dateTimeString): self
    {
        $dateTime = DateTimeImmutable::createFromFormat(DateTime::RFC3339, $dateTimeString);

        ($dateTime !== false) or throw new InvalidDateTime::invalidRFC3339String($dateTimeString);

        return new self($dateTime);
    }

     public static function createFromParts(int $year, int $month, int $day, int $hour, int $minute, int $second, string $timezone): self
    {
        (checkdate($month, $day, $year) && self::isValidTime($hour, $minute, $second)) or throw InvalidDateTime::invalidDateParts($year, $month, $day, $hour, $minute, $second, $timezone);

        $dateTime = new DateTimeImmutable();

        $dateTime = $dateTime
            ->setDate($year, $month, $day)
            ->setTime($hour, $minute, $second)
            ->setTimezone(new DateTimeZone($timezone));

        return new self($dateTime);
    }

    private static function isValidTime(int $hour, int $minute, int $second): bool
    {
        return ($hour >= 0 && $hour <= 23) && ($minute >= 0 && $minute <= 59) && ($second >= 0 && $second <= 59);
    }

    public static function now(): self 
    {
        return new self(new DateTimeImmutable());
    }

    public function getDateTime(): DateTimeImmutable
    {
        return $this->dateTime;
    }

    // __toString & equals methods
}

// Usage examples
$dateTime1 = DateTimeValueObject::createFromTimestamp(1703430593);
$dateTime2 = DateTimeValueObject::createFromRFC3339('2023-12-24T16:09:53+01:00');
$dateTime3 = DateTimeValueObject::createFromParts(2023, 12, 24, 16, 9, 53, 'Europe/Rome');
$dateTime4 = DateTimeValueObject::now();
Enter fullscreen mode Exit fullscreen mode

Let's focus on some details:

  • Constructor Accessibility: In this example, the constructor is marked as private, restricting instantiation to within the class itself. However, it's essential to note that this is a design choice, not a strict requirement. Constructors can also be public, depending on the desired encapsulation and usage patterns. The private constructor here emphasizes controlled instantiation through factory methods, providing a clear interface for creating instances.

  • Factory Methods with Input Validation: Each factory method includes input validation to ensure the integrity of the provided data before creating the DateTimeValueObject. Whether the constructor is private or public, the factory methods act as gatekeepers, enforcing validation rules.

  • createfromParts Method: This method showcases the flexibility of creating a DateTimeValueObject by specifying individual parts. The input validation within this method ensures that the constructed object reflects a valid date and time.

  • now Method: The now method exemplifies a common factory method for creating instances representing the current date and time. It leverages the DateTimeImmutable class internally to capture the current moment.

  • __toString & equals Methods: While not explicitly demonstrated in this example, implementing __toString for obtaining a string representation and equals for comparing instances are typical practices.

This approach highlights the versatility of using private or public constructors based on design preferences, reinforcing the idea that design patterns accommodate varying needs and choices.

Another interesting use of the factory method is to simplify the instantiation of a composite value object, like the Address Value Object seen before.

readonly final class Address 
{
    private function __construct(
        public Street $street,
        public City $city,
        public PostalCode $postalCode
    ) {}

    public static function create(
        string $street, 
        string $city, 
        string $postalCode
    ): Address 
    {
        return new Address(
            new Street($street),
            new City($city),
            new PostalCode($postalCode)
        );
    }

    // ... (rest of the methods)
}
Enter fullscreen mode Exit fullscreen mode

As mentioned before, the private constructor keeps things tidy by forcing developers to use only the create to obtain a new instance of the Value Object.

Also, with PHP 8, we can use a very cool trick:

$data = [
   'street'     => 'Via del Colosseo, 10',
   'city'       => 'Rome',
   'postalCode' => '12345'
];

$address = Address::create(...$data);
Enter fullscreen mode Exit fullscreen mode

This concise PHP 8 trick utilizes named arguments and array spread operator, showcasing a succinct and expressive method for object instantiation.

Alternatives to exceptions

Some people might have reservations about using exceptions as they interrupt the execution flow and, if not handled, can create issues.

However, there are alternative, more functional approaches that can come to our aid.

Either

For those unfamiliar with the concept of Either, we could succinctly (and poorly) describe it as a type that can be either the right value or not (and the opposite of right is left).

If you wanna know more, take a look here or here.

In a simplified implementation, it might look like this:

/**
 * @template L
 * @template R
 */
final class Either
{
    /**
     * @param bool $isRight
     * @param L|R $value
     */
    private function __construct(private bool $isRight, private mixed $value)
    {
    }

    /**
     * @param L $value
     * @return Either<L, R>
     */
    public static function left(mixed $value): Either
    {
        return new self(false, $value);
    }

    /**
     * @param R $value
     * @return Either<L, R>
     */
    public static function right(mixed $value): Either
    {
        return new self(true, $value);
    }

    /**
     * @return bool
     */
    public function isRight(): bool
    {
        return $this->isRight;
    }

    /**
     * @return L|R
     */
    public function getValue(): mixed
    {
        return $this->value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's apply Either in our Address Value Object creation:

readonly final class Address 
{
   // ... (rest of the methods)

    /**
     * @returns Either<InvalidValue,Address>
     */
    public static function create(
        string $street, 
        string $city, 
        string $postalCode
    ): Either
    {
        try {
            return Either::right(new Address(
                new Street($street),
                new City($city),
                new PostalCode($postalCode)
            ));
        } catch (InvalidValue $error) {
           return Either::left($error);
        } 
    }

    // __toString & equals methods
}
Enter fullscreen mode Exit fullscreen mode

Handling the result:

$address = Address::create('', '', '');

if ($address->isRight()) {
   // do stuff in case of success
} 
else {
   // do stuff in case of error

   /** @var InvalidValue $error */
   $error = $address->getValue();

   echo "Error: {$error->getMessage()}";
}
Enter fullscreen mode Exit fullscreen mode

This approach provides a flexible way to manage outcomes, allowing distinct handling paths for success and error scenarios.

While several libraries implement Eithers in PHP, the lack of generics requires heavy use of static analysis tools, like PSalm or PHPStan. That's why sometimes might be tricky working with types.

Union types

Alternatively, PHP 8.0 introduced the concept of Union Types. Similar to the Either example, the create method returns two possible values:

readonly final class Address 
{
   // ... (rest of the methods)

    public static function create(
        string $street,
        string $city, 
        string $postalCode
    ): InvalidValue|Address
    {
        try {
            return new Address(
                new Street($street),
                new City($city),
                new PostalCode($postalCode)
            );
        } catch (InvalidValue $error) {
           return $error;
        } 
    }

    // __toString & equals methods
}
Enter fullscreen mode Exit fullscreen mode

Handling the result:

$address = Address::create('', '', '');

if ($address instanceof InvalidValue) {
   // do stuff in case of error

   echo "Error: {$address->getMessage()}";
} 
else {
   // do stuff in case of success
}
Enter fullscreen mode Exit fullscreen mode

When it comes to handling errors in PHP, there is no one-size-fits-all solution. The decision between using Either and Union Types depends on your project's specific needs.

Either provides a granular approach, enabling you to distinctly manage different outcomes. It emphasizes a structured and explicit error-handling strategy.

Union Types, on the other hand, leverage the language's built-in capabilities and simplify the syntax. This approach might be more aligned with the philosophy of "let it fail fast" by handling errors directly where they occur.

In conclusion, choosing the right error-handling approach in PHP involves thoughtful consideration of your project's context and needs. Either and Union Types are valuable tools, providing flexibility to tailor your strategy. The key is selecting an approach that aligns seamlessly with your project's philosophy, promoting clarity, maintainability, and resilience.

Conclusion

In wrapping up our exploration of Value Objects in PHP, we've covered various aspects to enhance your understanding and usage of this important tool.

We started by looking at Simple Value Objects, which represent basic concepts in your code. These objects encapsulate single values and come with guidelines like keeping them focused, making them immutable, ensuring validation, having a string representation, and providing a method for equality checks. By sticking to these principles, we can create clear and reliable code.

Moving on to Complex Value Objects, we tackled structures with more intricacies. These objects handle multiple attributes or complex structures, modeling richer concepts in your domain. The guidelines for complex value objects involve representing the domain concept well, implementing validation, having a meaningful string representation, and providing a method for equality checks.

The journey peaked with Composite Value Objects, where we saw the combination of multiple simple or complex value objects into a unified structure. This allows us to represent even more complex concepts, like addresses in our example. The guidelines here involve assembling different value objects, abstracting complex concepts, ensuring a readable string representation, and providing a method for equality checks.

Next, we explored Factory Methods & Private Constructors. We saw how these can be beneficial when dealing with value objects with internal representations differing from their externals. The DateTimeValueObject served as an example, showcasing the use of factory methods for controlled object instantiation. The flexibility of using private or public constructors was highlighted, emphasizing design choices.

In our final leg, we looked at Alternatives to Exceptions, introducing the Either type and PHP 8.0's Union Types. Both these approaches offer different ways of handling errors. Either provides a structured strategy, and Union Types simplify syntax for a "fail fast" philosophy.

In conclusion, as you navigate PHP development, choosing between Either and Union Types depends on your project's needs. Both are valuable tools, offering flexibility to tailor your error-handling strategy. The key is selecting approaches that align with your project's context, ensuring code clarity, maintainability, and resilience. As you explore these possibilities, may your code be strong, your abstractions meaningful, and your decisions well-informed. Happy coding!

Top comments (0)