Skip to content
Muhammet Şafak
tr
Languages 4 min read

Designing Value Objects in Modern PHP

Embedding meaning into code instead of relying on primitives: why and how to design value objects in PHP.


We store a user’s email address as a string. We keep an order total as a float. We carry coordinates as two separate float values. This approach works — but it comes at a cost: the code itself has no idea what that string actually means, what rules it must satisfy, or when it should be considered valid or invalid.

Value objects are the answer to this loss of meaning. The concept comes from DDD (Domain-Driven Design) literature, but it can be applied in any project. The essence is simple: immutable objects that represent a concept, where the value matters — not the identity.

Why primitive obsession is a problem

When primitive types proliferate in code, a few things become inevitable:

Validation logic multiplies and scatters. Email validation ends up in a controller, a form request, maybe a model event. Which one is correct? All of them? None of them?

The type system can’t help you. Looking at a signature like createOrder(string $email, float $amount), you can’t tell whether those two strings are interchangeable — not at compile time, not in your IDE.

When a rule changes (email addresses now follow a different rule), you have to track down every place that uses it. Each place you miss is a bug waiting to happen.

A simple value object: Email

final class Email
{
    private string $value;

    public function __construct(string $value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException(
                "Geçersiz e-posta adresi: {$value}"
            );
        }
        $this->value = strtolower($value);
    }

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

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

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

An invalid email address simply cannot enter the system — the object can’t be constructed with one. Validation lives in exactly one place. A function signature that accepts an Email makes it immediately clear what that parameter represents.

Strengthening immutability with PHP 8.1 readonly

The readonly keyword introduced in PHP 8.1 lets you write value objects more cleanly:

final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Para miktarı negatif olamaz.');
        }
        if (strlen($currency) !== 3) {
            throw new \InvalidArgumentException('Para birimi 3 harfli ISO kodu olmalıdır.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \LogicException('Farklı para birimleri toplanamaz.');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function equals(self $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;
    }
}

Money here is immutable. add() returns a new object — it does not mutate the existing one. This is the fundamental rule of value objects: once constructed, the internal state never changes. If you need a different value, you create a new object.

Equality is measured by value, not identity

The defining characteristic of value objects is how equality is determined. Two distinct Email instances that hold the same email address are equal — it doesn’t matter which object reference you’re holding. This is fundamentally different from entities, where equality is based on identity.

$email1 = new Email('[email protected]');
$email2 = new Email('[email protected]');

$email1->equals($email2); // true — both were normalized

When to use value objects

Not every primitive needs to be wrapped. These questions serve as a guide:

  • Does this value have a specific validity rule?
  • Does this value need business logic written around it?
  • Is the same validation repeated in different parts of the codebase?
  • Can passing this type to the wrong place be caught by the compiler or the IDE?

If you answer “yes” to even one of those questions, a value object will likely pay for itself.

Coordinates, monetary amounts, email addresses, phone numbers, color codes, URLs — all of these are value object candidates. On the other hand, fields like a user’s display name or a page title that are purely strings with no business rules attached don’t need this treatment.

Conclusion

Value objects make code more verbose, but they prevent the loss of meaning. Looking at a method signature and immediately understanding what it accepts and what rules it operates under is far faster than deciphering a pile of primitives. That speed advantage compounds into a significant maintenance benefit over the lifetime of a long-running project.

Modern PHP — readonly properties, constructor promotion, strong naming conventions — makes this pattern easier to write than ever before. The rationale has been solid for years; the tooling has now caught up.

Tags: #PHP
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind