Skip to content
Davor Minchorov

Building Maintainable PHP Applications: Value Objects

This article is part of the Building Maintainable PHP Applications series

Have you ever worked on a project where part of the service class or a command handler class had at least one or more validation checks before executing the main code that was supposed to be executed?

By validation checks I mean things like numbers less than 0 (example: calculating price or discount percentage), checking if a string is in a valid format (example: invoice number or invitation code) or maybe a number being in a specific range (example: maximum number of people in a team, latitude and longitude).

The first thing that most developers may think is that the validation for such business rules should be done as part of the request validation or that it’s fine to stay in the service class.
The problem is that such business rules may not always come from a usual http web request, there are other delivery mechanisms like

  • webhooks

  • console commands or cron jobs

  • a message queue such as Kafka or RabbitMQ

  • A 3rd party API response

which means these rules may need to be reused in multiple places depending on the scenario.

The other problem that I can see is that the code becomes more complex to manage and work with the more rules you add to the service or command handler.

Primitive values such as:

  • String

  • Boolean

  • Integer

  • Float

  • Double

only show us the type of the value that is being process but they don’t show us the correct format or if the value is valid by default.

We have to validate them every time, and if we are not careful, even overwrite them by mistake if we are not able to easily understand the code, meaning the values are easily mutable.

Let’s take a look at some Laravel code example of how it may look like:

1<?php
2 
3class CalculateReservationPrice
4{
5 public function __invoke(array $data)
6 {
7 $office = Office::findOrFail($data['id']);
8 
9 $numberOfDays = Carbon::parse($data['end_date'])->endOfDay()->diffInDays(
10 Carbon::parse($data['start_date'])->startOfDay()
11 ) + 1;
12 
13 $price = $numberOfDays * $office->price_per_day;
14 
15 if ($numberOfDays >= 28 && $office->monthly_discount) {
16 $price = $price - ($price * $office->monthly_discount / 100);
17 }
18 
19 return $price;
20 }
21}

In short, the service class example here is trying to:

  • calculate the number of days

  • calculate the normal price

  • calculate the special price with an applied discount if the reservation is for more than or equal to 28 days and if the office has a monthly discount

If we focus more on the normal price calculation,

  • Is the price calculated in cents or in floats?

  • Is the price in dollars or in euros?

  • Is the price below zero?

  • Can the price be below zero?

Same questions can be asked for the discounted price based on the condition.

So what’s the solution? Value objects.

Value objects are objects that

  • Represent a value

  • Are immutable, meaning that they cannot be changed once instantiated,

  • Can represent one or multiple values

  • Validate the value before instantiating the object

  • Are instantiated via named constructors

  • Have methods that convert, compare or format the value

  • Do not have an identity

Let’s take a look at an example:

1<?php
2 
3final readonly class Price
4{
5 public function __construct(private int $value)
6 {
7 if ($value < 0)
8 {
9 throw new PriceCannotBeBelowZero();
10 }
11 }
12 
13 public static fromInteger(int $value): self
14 {
15 return self($value);
16 }
17 
18 private function convertToDollars(): float
19 {
20 return $this->value / 100;
21 }
22 
23 public function formattedWithSymbol(): string
24 {
25 return "$" . number_format($this->convertToDollars(), 2);
26 }
27 
28 public function value(): int
29 {
30 return $this->value;
31 }
32}

As you can see in the example above:

  • The class has a named constructor - a static method named fromInteger()

  • Has a dollar conversion method - convertToDollars

  • Has validation logic in the constructor which checks if the value is below zero

  • Has a formatted version with the dollar sign in front - formattedWithSymbol()

And this is how it is usually used in the code:

1<?php
2 
3$price = Price::fromInteger($price);
4 
5$price->formattedWithSymbol();
6$price->value();

There are 2 types of value objects:

  • Simple - they represent a single value

  • Complex - they represent multiple values

The example above is a simple value object because it represents a single primitive value in US dollar cents.

If this was a complex value object, it would represent the currency as well:

1<?php
2 
3declare(strict_types=1);
4 
5final class Money
6{
7 use MoneyFactory;
8 
9 private string $amount;
10 
11 public function __construct(int|string $amount, private readonly Currency $currency)
12 {
13 if (filter_var($amount, FILTER_VALIDATE_INT) === false) {
14 $numberFromString = Number::fromString((string) $amount);
15 if (! $numberFromString->isInteger()) {
16 throw new InvalidArgumentException('Amount must be an integer(ish) value');
17 }
18 
19 $this->amount = $numberFromString->getIntegerPart();
20 
21 return;
22 }
23 
24 $this->amount = (string) $amount;
25 }
26}

As you can see, the currency is also being passed to the constructor when it is instantiated so it would be used something like this:

1<?php
2 
3$fiveEur = Money::EUR(500);
4$tenEur = $fiveEur->add($fiveEur);

The example is taken from the packagemoneyphp/money (which you can use for money related calculations).

You can use value objects when you work with:

  • Money

  • Addresses

  • Email Addresses

  • Phone Numbers

  • Latitude and Longitude

  • Invitation Codes

  • Invoice Numbers

  • and many more

The only thing that you will differentiate the value objects from entities is that they don’t contain identifiers.

Now that you know a thing or two about value objects, you can see how some parts of the code from the service class can be moved to separate classes which can deal with the specifics of primitive values.

Useful links