At last year’s php[tek], one of my biggest “holy cow, why haven’t I been doing this?!” moments came from my friend Andrew Cassell when he explained PHP Value Objects in the context of Domain-Driven Design.
Put simply, a Value Object is an immutable object that encapsulates some data and will always be in a valid state.
For a bare-bones example, let’s encatsulate my cat, Taco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Cat { private string $name; public function __construct(string $name) { $this->name = $name; } public function getName(): string { return $this->name; } } $taco = new Cat('Taco'); |
Now I can pass my Cat
instance around and I know that it will always be valid.
I could type-hint against it:
1 2 3 4 |
function feedCat(Cat $cat, int $portions = 1) { // ... } |
…or even filter a collection of animals to find the cats:
1 |
$cats = array_filter($animals, fn ($animal) => $animal instanceof Cat); |
Anywhere I have a Cat
object, I also know that it will have a getName()
method that will return a string.
Value Objects should always be in a valid state
What we can’t be sure of (yet, anyway) is that the name will be empty. After all, new Cat('')
is technically valid, right?
Let’s add some validation into our Cat
definition:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Cat { private string $name; public function __construct(string $name) { if (empty(trim($name))) { throw new InvalidArgumentException('Name your cat!'); } $this->name = $name; } public function getName(): string { return $this->name; } } |
Now, if we try to construct a Cat
with an empty name, we’ll get an InvalidArgumentException
:
1 2 |
new Cat(''); Fatal error: Uncaught InvalidArgumentException: Name your cat! |
If we add other properties to the object, we can validate those, too. The end goal is that once we’ve constructed the Value Object, we know that it’s in a valid state!
This can also be a great place to leverage custom Exception
classes: InvalidArgumentException
is a great default, but it’s even easier to understand what’s going on if you’ve defined an InvalidAnimalNameException
class.
Understanding immutability
Another key feature of Value Objects is that they are immutable, meaning that once they’re created they do not change. These are not the same as (for instance) a database model; there’s no primary key nor save()
method, because that’s not the purpose of a Value Object.
Notice how there’s no setName()
method in our Cat
class? If you want a cat with a different name, create one!
A more practical example
I like to think of Value Objects as custom types for handling certain types of data. One common use in web applications would be email addresses: while these are strings, not all strings are valid emails. If you try to send a newsletter to the email address “taco”, you’re going to have a very confused mail server.
Additionally, email addresses are one of those things that we tend to validate over and over again, which means our codebases become littered with calls to filter_var($email, FILTER_VALIDATE_EMAIL)
.
Value Objects can help us here, because we can put that validation in one place and be sure that it’s valid:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Email { private string $email; public function __construct(string $email) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException(sprintf( '"%s" is not a valid email address.', $email )); } $this->email = $email; } public function getEmail(): string { return $this->email; } } |
Now, any time we’re given an email address as a string, we can throw that into our Email
value object; if the address is invalid, we’ll get an exception telling us so. We also know that getEmail()
is guaranteed to give us a valid email address:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ResponseInterface; public function create(ServerRequestInterface $request): ResponseInterface { $request_body = $request->getParsedBody(); try { $email = new Email($request_body->email_address); } catch (InvalidArgumentException $e) { // Return validation errors... } $instance = new User(); // We know that $email->getEmail() will always be valid! $instance->email = $email->getEmail(); // ... } |
We don’t need to worry about invoking any other sort of validation tool, because we’ve already defined how we want to validate email addresses in the Email
class!
Adding other methods
A Value Object isn’t limited to just the values you pass into the constructor. Let’s say we wanted an easy way to get the domain from an email address; we might write a getDomain()
method that looks something like this:
1 2 3 4 5 6 7 8 9 10 11 |
class Email { // ... public function getDomain(): string { $parts = explode('@', $this->email, 2); return array_pop($parts); } } |
Note: I’ve chosen to split email addresses on the “@” symbol, but this is for the sake of simplicity in this example; RFC 822 and its kin are a whole separate can of worms!
Now, anywhere we need to get the domain associated with an email address, it’s as simple as calling getDomain()
on the Value Object!
Unit testing Value Objects
Another great aspect of Value Objects is that they’re very straight-forward to test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
<?php use PHPUnit\Framework\TestCase; final class EmailTest extends TestCase { /** * @dataProvider provideValidEmailAddresses */ public function testGetterMethods(string $email, string $domain): void { $instance = new Email($email); $this->assertSame($email, $instance->getEmail()); $this->assertSame($domain, $instance->getDomain()); } /** * @dataProvider provideInvalidEmailAddresses */ public function testEmailAddressValidation(string $email): void { $this->expectException(InvalidArgumentException::class); new Email($email); } /** * Data provider full of valid email addresses. * * @return iterable<array{string, string}> */ public function provideValidEmailAddresses(): iterable { return [ ]; } /** * Data provider full of invalid email addresses. * * @return iterable<array{string}> */ public function provideInvalidEmailAddresses(): iterable { yield 'Empty string' => ['']; yield 'No domain' => ['Taco']; } } |
That’s it! Now anywhere you use the Email
class you have a fully-tested, immutable, and always-valid email address!
Joe T
Awww. Such a cute kitty.
i know for the sake of simplicity it’s easy to write “array_pop(some_expression())” but it always makes me flinch.
Aside from that, thanks for the post. :)
Steve
Oof, you’re 100% right. Thank you for the code review, I’ve updated it to avoid passing anything but a variable by reference.
You win 10 Taco Points!