← All resources

/// Article — Sep 2017

Your Types Are Correct. Your Code Is Still Wrong.

/// Based on the talk

Don't Be So Primitive

PHP-SW, May 2017. The video goes through the same ideas in more detail.

Watch the talk →

This line of code has a bug:

$campaign->addAddress('6 Lower Park Row, Bristol');

addAddress() expects an email address. That's a postal address.

PHP won't complain. Your IDE won't complain. PHPStan won't complain. The method signature says string $address, and '6 Lower Park Row, Bristol' is a perfectly good string.

You'll find out weeks later, when the marketing emails go out and something falls over. By then, working out where that bad data came from is a job in itself.

The problem isn't a missing type. It's that string is too weak a type to say what this value actually is.

Primitives can't tell your tools what you mean

Type declarations are great. Write function process(User $user) and try to pass it an int, and your IDE flags it as you type. The bug never makes it past your keyboard.

But for everything that isn't an object, we tend to fall back on primitives: string, int, float, bool.

All of these are valid strings:

  • dave@example.com
  • fredblogs.com
  • fred.blogs
  • 6 Lower Park Row, Bristol

Only one of them is a valid email address. As far as your tools are concerned, they're all the same.

Wrap it in a value object

A value object is a small class that wraps a single domain value:

final readonly class EmailAddress
{
    public function __construct(public string $value)
    {
        if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
            throw new InvalidArgumentException("Invalid email address [$value]");
        }
    }
}

Now change the method signature:

public function addAddress(EmailAddress $address): void

Two things just happened.

Your tools can see the bug. Pass a raw string and your IDE and PHPStan both flag it immediately. To get past them, someone has to write new EmailAddress('6 Lower Park Row, Bristol'). That's possible, but the mistake is glaring, and it's the kind of thing that gets spotted in code review.

An EmailAddress is always valid. The constructor won't let an invalid one exist. Every piece of code that receives an EmailAddress can trust it without checking again.

And if someone does try to construct one with bad data, the exception fires at the exact point the bug was introduced. Not weeks later, three systems away, when the emails are sent.

They're a natural home for logic

Once you have a value object, you'll find other things want to live there.

Normalisation. Email addresses are case-insensitive, so store them lowercased. UK postcodes can be typed as BS1 1AB, bs11ab or BS11AB. Normalise them to one format in the constructor, and add methods for each format you need to output.

Domain rules. On one project I had a Point value object for latitude and longitude. The obvious validation is the valid range for each. But the project was UK only, so I tightened it to the UK's bounds. That would instantly catch latitude and longitude being swapped, which is exactly the mistake that once cost me an afternoon.

Equality that makes sense. Two points a few metres apart might be "the same place" for your application. An equals() method on the value object is the right place for that rule, rather than every caller comparing floats.

Here's a warning sign that you need a value object: helper functions for one concept, like EmailUtils::normalise() or PostcodeHelper::format(), called from all over the codebase. That logic wants to live in one class, with the value it's about.

And for fixed sets of values, use an enum

When I first gave this talk, PHP didn't have enums, so I used value objects for things like game statuses (not started, active, finished) and settlement types (city, town, village).

Since PHP 8.1, just use an enum:

enum GameStatus: string
{
    case NotStarted = 'not_started';
    case Active = 'active';
    case Finished = 'finished';
}

Same idea, built into the language. A GameStatus can only ever be one of those values.

Don't turn everything into an object

Value objects are a tool, not a rule. A few guidelines I've found useful:

Start with the concepts used everywhere. Email addresses, money, postcodes, IDs. That's where you get the most benefit from validation living in one place. You don't need to convert every string in a legacy codebase at once.

Model real things. Latitude and longitude belong together in a Point, not as two separately wrapped floats. They mean nothing apart.

Keep them immutable. readonly makes this easy now. Take extra care if a value object contains arrays or other objects.

Check for existing packages. Money, in particular, is a solved problem. Don't write your own if a good library fits.

Not ready for value objects yet? Even a better name helps. Renaming addAddress(string $address) to addEmailAddress(string $emailAddress) makes the postal address bug much more likely to be spotted.

Where to start

Search your codebase for string $email. Count how many places accept one, and how many of them validate it.

Then write an EmailAddress class, use it in one method signature, and let PHPStan show you everywhere else it needs to go.

For the original talk, watch the video from PHP-SW.

Which primitive in your codebase is causing the most trouble?

Start a conversation

We help teams put these ideas into practice, through training, code review and working alongside you. Tell us where you are and what you're aiming for.