What's the value in Value Objects in PHP?
So I've been experimenting with the concept of "Value Objects" in PHP.
A fairly succinct definition that I found in my travels:
A Value Object is a small, simple object that represents a descriptive aspect of the domain (like a monetary amount, URL, or an email address) whose identity is defined by its value (its attributes), not by a unique ID.
They also shouldn't be confused with the arguably more common: "Entity" object, for example a "Customer", "User" or a "Product".
The key differences are:
- An Entity's identity is represented by a unique, usually persistent ID, whereas Value Objects are represented by their attributes/value.
- Two Entities with the same ID are considered equal if their IDs match, whereas two Value objects with exactly the same attributes/value are considered equal.
- Entities are usually mutable (you may want to update a "User" email for example), Value Objects are ideally immutable.
A simple example
Usually when you want to represent a value like an email you may reach for a string primitive:
$email = 'foo@example.com'
But what if we used a Value Object instead?
$email = new Email('foo@example.com');
"But WHY?" I hear you ask. Well a few reasons come to mind:
- They can be made to be immutable, once they're created you can't change that specific instance.
- You can break the value down into it's relevant typed attributes and make them accessible, maybe we want to extract the username or domain parts from the email?
- You can validate them upon creation to ensure you're always passing a "valid" state, e.g. guard against malformed emails (are they missing the "@" symbol?)
- You can ensure equality however you like, maybe using something like an "equals()" method and compare the two objects type and value.
So here's an idea of what an Email
readonly class Email implements Stringable
{
public string $email;
public string $username;
public string $domain;
public function __construct(string $email)
{
/**
* Ensure the Email object is only ever created in a valid state.
*/
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email address");
}
/**
* Lets extract the username and domain from the email address
*/
$parts = explode('@', $email);
$this->email = $email;
$this->domain = $parts[0];
$this->username = $parts[1];
}
/**
* We can check if two Email objects are equal by comparing their email addresses.
*/
public function equals(self $other): bool
{
return $this->email === $other->email;
}
/**
* This allows us to use the Email object just like a primitive string.
*/
public function __toString(): string
{
return $this->email;
}
}
$email = new Email('foo@example.com');
$email2 = new Email('foo@example.com');
$email3 = new Email('baz@example.com');
echo $email->domain; // example.com
echo $email->username; // foo
echo $email->equals($email2); // true
echo $email->equals($email3); // false
echo $email; //foo@example.com
A slightly more involved example
So now for a URL Value Object, I've written some comments in line to just highlight more key points.
/**
* Make the entire class 'readonly' (PHP 8.4) so that all the attributes are immutable
*/
readonly class Url implements Stringable
{
public string $url;
public string $scheme;
public string $host;
public string $path;
public ?string $query;
public ?string $fragment;
public function __construct(string $url)
{
// You can validate the URL here and ensure that it's always created in a valid state.
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new InvalidArgumentException("The supplied URL is not valid: $url");
}
$parsedUrl = parse_url($url);
if (!$parsedUrl) {
throw new InvalidArgumentException("Unable to parse the url: $url");
}
//Extract all the useful parts of the URL that you may want to reference later!
$this->url = $url;
$this->scheme = $parsedUrl['scheme'];
$this->host = $parsedUrl['host'];
$this->path = $parsedUrl['path'] ?? '/';
$this->query = $parsedUrl['query'] ?? null;
$this->fragment = $parsedUrl['fragment'] ?? null;
}
/**
* Maybe you want to check if a query string variable is present with a certain value?
*
* @param string $name
* @param string|null $withValue
*
* @return bool
*/
public function hasQueryStringVariable(string $name, ?string $withValue = null): bool
{
if (empty($this->query)) {
return false;
}
parse_str($this->query, $vars);
if (!isset($vars[$name])) {
return false;
}
if ($withValue !== null) {
return $vars[$name] === $withValue;
}
return true;
}
/**
* Do two urls hosts match?
*
* @param Url $url
*
* @return bool
*/
public function hasSameHost(self $url): bool
{
return $url->host === $this->host;
}
/**
* Maybe you want the same URL but with a different set of query params?
* @param array $queryStringVars
*
* @return self
*/
public function withQueryString(array $queryStringVars): self
{
$queryString = http_build_query($queryStringVars);
$url = $this->scheme . '://' . $this->host;
if (!empty($this->path)) {
$url .= $this->path;
}
if ($queryString) {
$url .= '?' . $queryString;
}
if (!empty($this->fragment)) {
$url .= '#' . $this->fragment;
}
return new self($url);
}
/**
* Maybe you want the same URL but with a different scheme?
*
* @param string $scheme
*
* @return self
*/
public function withScheme(string $scheme): self
{
$url = $scheme . '://' . $this->host;
if (!empty($this->path)) {
$url .= $this->path;
}
if (!empty($this->query)) {
$url .= '?' . $this->query;
}
if (!empty($this->fragment)) {
$url .= '#' . $this->fragment;
}
return new self($url);
}
/**
* Is it a secure URL?
*
* @return bool
*/
public function isSecure(): bool
{
return $this->scheme === 'https';
}
/**
* How to check if two objects are considered equal in value?
*
* @param Url $url
*
* @return bool
*/
public function equals(self $url): bool
{
return $this->url === $url->url;
}
/**
* Etc, you could keep adding utilities as you see fit! These were just a few ideas.
*/
/**
* And then use it just like you would a normal string!
*
* @return string
*/
public function __toString(): string
{
return $this->url;
}
}
$url = new Url('https://www.google.com/search?foo=bar&baz=qux&bozz=bam&foob#anchor');
$url2 = new Url('https://www.google.com');
echo $url->scheme.PHP_EOL; // https
echo $url->host.PHP_EOL; // www.google.com
echo $url->path.PHP_EOL; // /search
echo $url->query.PHP_EOL; // foo=bar&baz=qux&bozz=bam&foob
echo $url->fragment.PHP_EOL; // anchor
echo($url->hasSameHost($url2) ? 'Yes' : 'No').PHP_EOL; // Yes
echo($url->hasQueryStringVariable('foo', 'bar') ? 'Yes' : 'No').PHP_EOL; // Yes
echo $url->withQueryString(['foober' => 'bazber']).PHP_EOL; // https://www.google.com/search?foober=bazber#anchor
// __toString() means we can do things like this too and it'll just work:
echo $url; // https://www.google.com/search?foo=bar&baz=qux&bozz=bam&foob#anchor
$contents = file_get_contents($url);
//Ensuring equality
$urlA = new Url('https://example.com/path');
$urlB = new Url('https://example.com/path');
$urlC = new Url('https://example.com/other');
echo 'URL A: ' . $urlA . PHP_EOL;
echo 'URL B: ' . $urlB . PHP_EOL;
echo 'URL C: ' . $urlC . PHP_EOL;
echo '$urlA equals $urlB: ' . ($urlA->equals($urlB) ? 'Yes' : 'No') . PHP_EOL; // Yes
echo '$urlA equals $urlC: ' . ($urlA->equals($urlC) ? 'Yes' : 'No') . PHP_EOL; // No
Hopefully the above code has highlighted why Value Objects can be a useful pattern to incorporate.