Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Value Objects in PHP: Small Classes That Prevent Big Bugs

About Post

Here's a function signature that looks completely harmless:

public function chargeRent(int $amount, string $currency, string $email): void

Now count the ways to call it wrong. Pass the amount in riyals when it expects dirhams, and the tenant is charged a hundredth of their rent. Pass 'qar' instead of 'QAR'. Swap the currency and the email, which are both strings, and PHP won't blink. Pass an email with a trailing space from a copy-paste, and the receipt never arrives.

Every one of those is a real kind of bug, and the type system can't help because everything is an int or a string. Value objects fix this with almost embarrassingly small classes.

What a value object is

A value object is a small object defined entirely by its values, not by an identity. Two Money objects holding 500 QAR are the same thing, in the same way two identical banknotes are interchangeable. A Tenant, by contrast, is an entity: two tenants with the same name are still two different people.

Good value objects have three properties:

  • Always valid. The constructor refuses bad data. If you're holding one, it's correct.
  • Immutable. Once created, it never changes. Operations return a new object.
  • Compared by value. Equality means "same values", not "same instance".

Modern PHP makes all three easy. Let's build the three I reach for most.

Money

final readonly class Money
{
    public function __construct(
        public int $amount,      // minor units: dirhams, cents
        public string $currency,
    ) {
        if (! preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException("Bad currency: $currency");
        }
    }

    public function add(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new LogicException('Cannot add different currencies.');
        }

        return new self($this->amount + $other->amount, $this->currency);
    }

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

A few decisions are baked in here, and each one prevents a known bug:

  • Integers in minor units. Floats can't represent most decimal amounts exactly, which is how you end up with totals that are off by a fraction. Storing 50000 dirhams instead of 500.00 riyals avoids that entirely.
  • Currency travels with the amount. You can't add QAR to USD by accident. The mistake becomes an exception at the exact line where it happened, not a wrong number in a report next month.
  • readonly class. Since PHP 8.2, a readonly class makes every property write-once. Nobody can do $rent->amount = 0 somewhere far away.

Email

final readonly class Email
{
    public string $value;

    public function __construct(string $value)
    {
        $value = strtolower(trim($value));

        if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
            throw new InvalidArgumentException("Invalid email: $value");
        }

        $this->value = $value;
    }

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

The normalisation lives in one place. Trimming and lowercasing happen every time, so "[email protected] " and "[email protected]" can never become two accounts. (Strictly speaking, the part before the @ can be case-sensitive, but almost no real mail server treats it that way. Lowercasing is a pragmatic choice; make it consciously.)

And the signature from the start becomes chargeRent(Money $rent, Email $receiptTo). You can no longer swap the arguments, and you can no longer pass an amount without a currency.

DateRange

Date pairs are where bugs love to hide: a lease that ends before it starts, an "inclusive or exclusive?" argument in every function, overlap checks written slightly differently in three places.

final readonly class DateRange
{
    public function __construct(
        public CarbonImmutable $start,
        public CarbonImmutable $end,
    ) {
        if ($end->lessThan($start)) {
            throw new InvalidArgumentException('End is before start.');
        }
    }

    public function contains(CarbonImmutable $date): bool
    {
        return $date->betweenIncluded($this->start, $this->end);
    }

    public function overlaps(DateRange $other): bool
    {
        return $this->start <= $other->end && $other->start <= $this->end;
    }
}

Now "can this unit be leased for these dates?" is one well-tested method, $existing->overlaps($requested), instead of a comparison someone retypes from memory.

Why immutability matters so much

Notice CarbonImmutable, not Carbon. That's deliberate. Regular Carbon instances are mutable, which produces this classic bug:

$firstDue = $contract->start_date;
$secondDue = $firstDue->addMonth(); // also changed $firstDue!

With a mutable object, addMonth() changes the original, so the contract's start date silently moves too. Immutable objects make that impossible: addMonth() returns a new instance and leaves the old one alone. If your app is new, consider calling Date::use(CarbonImmutable::class) in a service provider so Eloquent returns immutable dates everywhere.

The same thinking applies to your own value objects. When they're immutable, you can pass them anywhere, cache them, and share them between objects without wondering who might change them. If you want "change one field" methods, return a new object. PHP 8.5 also lets you write them with the new clone($this, ['amount' => $amount]) syntax, inside the class.

The test for a value object: if a concept has rules (a format, a range, a unit, a relationship between two fields), and you've written those rules in more than one place, it wants to be a class.

Plugging them into Eloquent

Value objects become really pleasant when your models hand them to you automatically. A custom cast can even map one object to two columns:

class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money((int) $attributes["{$key}_amount"], $attributes["{$key}_currency"]);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return [
            "{$key}_amount" => $value->amount,
            "{$key}_currency" => $value->currency,
        ];
    }
}

Register it in the model's casts() method with 'rent' => MoneyCast::class, and $contract->rent is now a Money object backed by rent_amount and rent_currency columns. The rest of your code never touches raw integers again. (Simplified: a production version would also handle null values.)

When not to bother

Don't wrap every string. A blog post title with no rules doesn't need a Title class. Value objects earn their place when the concept has validation, units, formatting or behaviour, and when getting it wrong costs something. Money, emails, phone numbers, date ranges, percentages and identifiers like a Commercial Registration number are the usual winners.

Start with one. Money is the best candidate in almost any business app. Once you've seen a currency bug turn into an exception on the exact line that caused it, you'll start spotting the next one everywhere.

Which value object would you add first in your current codebase? And do you have one you've written that saved you from a bug you still remember?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close