Unlocking PHP Value Objects: Transform Your Code for Clarity and Maintainability Now

Hire a PHP developer for your project — click here.

by admin
php_value_objects_explained

Why value objects matter more than we admit

There’s a quiet moment most of us know.

It’s late. The office is empty or the kitchen light is the only one still on. You’re staring at a failing test that says something annoyingly vague like:

Expected "DE" but got "DE "

You scroll. And scroll. Some $address, some $country, some $code that started life as a “quick string” a year ago and has since grown into a hydra of edge cases, conditionals, and half-broken validations.

Somewhere in there, a country code became everyone’s responsibility and therefore no one’s.

This is where PHP Value Objects quietly walk in, put a hand on your shoulder, and say:

“Stop passing strings. Give the idea a home.”

Friends, if you work with PHP long enough — whether you’re searching for a job, hiring for one, or fighting with a fragile legacy codebase — you’ll eventually run into the same question:

What if our data types actually meant something?

That’s what value objects are about.

Not patterns for the sake of patterns. Not “DDD cosplay”. But a very pragmatic way to turn abstract business rules into concrete, reliable, testable pieces of code.

What a value object really is (and what it’s not)

The textbook definition:

A value object is an immutable, self-validating object that represents a concept in your domain by its value, not by identity.

Let’s unpack that in normal developer language.

  • Represents a concept
    It’s not “just a string”.
    It’s an Email, a Money, a UserId, a DiscountRate, a ProductSku, a PhoneNumber.

  • Equality by value, not by id
    Two Email objects with the same address are the same in terms of meaning, even if they’re different instances:

    $a = new Email('alice@example.com');
    $b = new Email('alice@example.com');
    
    // These should be equal in value
    assert($a->equals($b));
    
  • Immutable
    Once created, it never changes.
    If you want a different one, you build a new one. No “setters”, no hidden state changes. That makes reasoning about code much easier.

  • Self-validating
    Invalid stuff simply cannot exist.
    You don’t create an Email with “banana”, you throw early and loudly.

And just as important:

  • A value object is not an Entity
    It doesn’t have a database-generated id.
    It doesn’t “live” as a record. It’s usually a field on your entities.

  • A value object is not an anemic DTO
    DTOs are often just data buckets.
    Value objects carry meaning and rules.

Why your future self loves value objects

Zoom out from the code for a moment.

Imagine two teams.

  • Team A passes strings and arrays for everything: “email”, “price”, “status”, all primitive.
  • Team B wraps meaningful concepts into small, focused value objects: Email, OrderStatus, Money, VatNumber.

Fast forward 18 months.

Both teams have:

  • new teammates,
  • partially documented rules,
  • a pile of “quick fixes”,
  • business logic that changed three times and counting.

Team A is now debugging things like:

// Sometimes status is "paid", sometimes "PAID", sometimes 1.
if ($order['status'] == 'paid' || $order['status'] == 1) {
    // ...
}

Team B is reading code like:

if ($order->status()->isPaid()) {
    // ...
}

The more the product grows, the more primitive obsession (overuse of strings/int/arrays) turns into friction:

  • Data validation is duplicated everywhere.
  • Edge cases appear in random places.
  • One small change (“we now support 3-letter country codes”) breaks five unrelated modules.

With value objects:

  • You centralize rules.
  • You make illegal states unrepresentable.
  • You give newcomers a guided tour of the domain just by reading class names.

Is it slower to write? A bit at first.
Is it faster to maintain? Dramatically.

A small example: turning a string into an Email

Let’s take the classic example.

In many codebases, email is “just a string”:

class User {
    public function __construct(
        private string $email,
        // ...
    ) {}
}

Simple. Until it isn’t.

You start adding:

  • Validation in controllers.
  • Additional checks in services.
  • Silent assumptions in views.

You end up with five different regexes and three different error messages.

Now watch what happens when we introduce a value object:

final class Email
{
    private string $value;

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

        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid email address.');
        }

        $this->value = mb_strtolower($value);
    }

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

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

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

And the entity:

class User
{
    public function __construct(
        private Email $email,
        // ...
    ) {}

    public function email(): Email
    {
        return $this->email;
    }
}

What changed?

  • You can no longer create a user with a broken email.
  • Validation lives in exactly one place.
  • Comparisons become explicit and intention-revealing.
  • Tests become simpler and more focused.

Have you noticed the emotional effect of this kind of refactor?
Reading Email instead of string feels like opening a window in a stuffy room.

Immutability: the quiet superpower

Immutability sounds academic until you’ve been burned by a shared mutable object.

Picture this:

$price = new Money(1000, 'EUR');
$order->setPrice($price);

// Somewhere else
$price->applyDiscount(0.1);

Is the order now 10% cheaper? 20%?
Who knows. The same instance is shared by different parts of the system. Side effects sneak in silently.

With immutable value objects, you design them like this:

final class Money
{
    public function __construct(
        private int $amountInCents,
        private string $currency
    ) {}

    public function amount(): int
    {
        return $this->amountInCents;
    }

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

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);

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

    public function multiply(float $factor): self
    {
        return new self(
            (int) round($this->amountInCents * $factor),
            $this->currency
        );
    }

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch.');
        }
    }
}

Every “change” returns a new instance.

  • No unexpected side-effects.
  • Easy to reason about in concurrent or async contexts.
  • Bugs become more local and easier to track.

And when you’re building things in modern PHP frameworks — Laravel, Symfony, API Platform — this style pairs beautifully with:

  • readonly properties,
  • typed properties,
  • typed collections,
  • strict return types.

Immutable value objects line up nicely with the language itself.

Value objects in real PHP projects: from theory to keyboard

Value objects sound nice on paper, but let’s drop them into the kind of PHP codebases we actually see on Find PHP jobs and resumes:

  • Symfony / Laravel monoliths,
  • older code with a lot of arrays,
  • messy but alive systems that are too valuable to “rewrite from scratch”.
See also
How Long Does It Really Take to Become a PHP Developer: The Ultimate Timeline for Success

You don’t rebuild everything.

You pick your battles.

Where to start: smell the primitives

You know that feeling when you reread a part of the codebase and something smells off?

Look for:

  • Many parameters of primitive types in methods:

    public function createOrder(
        string $email,
        string $country,
        string $currency,
        float $total
    ) { ... }
    
  • Repeated validation patterns:

    • “is this email valid?”
    • “is this a supported currency?”
    • “is this ISO date correct?”
  • Magic strings and integers:

    if ($status === 'paid') { ... }
    if ($userType === 3) { ... }
    
  • “Helper” functions that attach to primitives:

    function normalizePhoneNumber(string $phone): string { ... }
    function normalizePrice(float $price): float { ... }
    

Each of these is a candidate for a value object.

A few classic examples in real-world PHP jobs:

  • Email
  • Money (or Price)
  • Currency (wraps the ISO code)
  • CountryCode
  • OrderStatus
  • UserId
  • ProductSku
  • VatNumber
  • PhoneNumber
  • Locale / LanguageCode

Once you add those, your method signatures start telling stories:

public function createOrder(
    Email $customerEmail,
    CountryCode $country,
    Money $total
): Order { ... }

You don’t just see types anymore. You see the domain.

Value objects in frameworks: Laravel and Symfony flavor

In modern PHP web apps, value objects appear everywhere once you start looking.

  • In controllers:

    public function store(Request $request)
    {
        $email = new Email($request->string('email'));
        $price = Money::fromFloat(
            $request->float('price'),
            'EUR'
        );
    
        // ...
    }
    
  • In forms / DTOs / commands:

    final class RegisterUserCommand
    {
        public function __construct(
            public readonly Email $email,
            public readonly HashedPassword $password
        ) {}
    }
    
  • In domain services:

    interface CurrencyConverter
    {
        public function convert(Money $amount, Currency $to): Money;
    }
    
  • In Eloquent or Doctrine models, with casting:

    • Laravel: custom casts for Money, Email, OrderStatus
    • Doctrine: custom types for Email, Money, Uuid, etc.

The point is not to make everything a value object.
The point is to turn the interesting and fragile parts of your domain into first-class citizens.

Talking about money: a deeper example

Money is where bugs hurt.

If your product charges users, pays vendors, calculates tax, or does discounts, you cannot afford fuzzy money handling. Floating point mistakes, lost rounding rules, currency mix-ups — they go straight to people’s pockets.

Here’s a pretty realistic Money value object in PHP:

final class Money
{
    private function __construct(
        private int $amountInCents,
        private string $currency
    ) {
        if ($amountInCents < 0) {
            throw new InvalidArgumentException('Money cannot be negative here.');
        }

        if (!preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException('Invalid currency code.');
        }
    }

    public static function fromFloat(float $amount, string $currency): self
    {
        return new self(
            (int) round($amount * 100),
            strtoupper($currency)
        );
    }

    public static function zero(string $currency): self
    {
        return new self(0, strtoupper($currency));
    }

    public function amountInCents(): int
    {
        return $this->amountInCents;
    }

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

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);

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

    public function subtract(Money $other): self
    {
        $this->assertSameCurrency($other);

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

    public function isGreaterThan(Money $other): bool
    {
        $this->assertSameCurrency($other);

        return $this->amountInCents > $other->amountInCents;
    }

    public function toFloat(): float
    {
        return $this->amountInCents / 100;
    }

    private function assertSameCurrency(Money $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch.');
        }
    }
}

Nothing exotic. Just:

  • explicit rules,
  • one place to handle conversions,
  • clear operations that avoid subtle bugs.

And now your feature code reads like this:

$total = Money::zero('EUR');

foreach ($cart->items() as $item) {
    $total = $total->add($item->totalPrice());
}

if ($total->isGreaterThan(Money::fromFloat(100, 'EUR'))) {
    // apply free shipping
}

This is the kind of clarity that makes on-boarding new developers so much easier — which matters a lot if you’re hiring or joining teams through platforms like Find PHP.

Persistence and serialization: making it work with databases and APIs

One of the first questions that pops up:

“But how do I save these to the database? Doctrine wants scalars. Eloquent wants scalars. JSON wants scalars.”

Two common strategies:

  • Map value objects to primitives at the edges

    • In entities/models, store their primitive representation.
    • In the constructor or getters, wrap/unwrap them.

    Example with Doctrine:

    class User
    {
        #[ORM\Column(name: "email", type: "string", length: 255)]
        private string $emailRaw;
    
        public function __construct(Email $email)
        {
            $this->emailRaw = (string) $email;
        }
    
        public function email(): Email
        {
            return new Email($this->emailRaw);
        }
    }
    
  • Custom casting / types

    • Laravel: custom cast class that transforms between string/int and your value object.
    • Doctrine: custom types to handle Email, Money, Uuid, etc.

Similar at the API boundary:

  • Incoming JSON → value objects in request DTOs.
  • Outgoing response DTOs → primitives for the outside world.

Inside your system, you work with rich value objects.
At the edges, you speak JSON and SQL like everyone else.

It’s a trade-off, but it’s worth it.

Value objects and careers: what they say about you

On a platform like Find PHP, people read code to guess what kind of developer you are.

Value objects send some interesting signals:

  • To employers
    They say:

    • you think about domain language,
    • you care about correctness,
    • you avoid primitive obsession,
    • you understand maintainability beyond the next sprint.
  • To other developers
    They say:

    • you respect the next person who will read this,
    • you don’t hide complexity under “just add one more param”,
    • you’re willing to give names to vague ideas.

And to yourself, years later, they whisper something else:

“You were doing more than making it pass. You were making it clear.”

It’s a small thing. But these small things accumulate in a codebase — and in a career.

A simple rule of thumb: where to use value objects

There’s a practical heuristic I like:

If a value has:

  • validation rules,
  • formatting rules,
  • behavior (operations),
  • known edge cases,
    then it wants to be a value object.

So:

  • Email: yes, of course.
  • Money: absolutely.
  • CountryCode, Locale, Currency: often yes.
  • FirstName, LastName: depends; if you need normalization/validation, yes.
  • Title, Description: usually fine as strings.

If you’re unsure, ask yourself a quiet question in front of the screen:

“Am I repeating myself around this value?”

If you are, you already know the answer.

Final thought: giving shape to meaning

There’s this subtle shift that happens when you start writing PHP with value objects in mind.

You stop thinking only in terms of:

  • controllers,
  • services,
  • models.

You start thinking in:

  • concepts,
  • constraints,
  • sentences in the problem domain.

You stop asking: “What type should this be?”
You start asking: “What is this thing we’re dealing with?”

Some days, coding is just typing and fixing imports.

But on the better days — the ones you remember — it feels like you’re gently carving shape out of formless requirements, trying to give structure to someone else’s idea so that it can live and evolve without collapsing under its own ambiguity.

Value objects are one of the quietest, most honest tools we have for that.

They don’t shout. They don’t promise magic.

They just sit there, small and solid, saying:

“This is what we mean here.”

And on a long week, when the tests are flaky and the meetings ran too long, that kind of clarity is often enough to keep you moving, one careful refactor at a time.
перейти в рейтинг

Related offers