serendipity_hq/component-value-objects
A Laravel/PHP value object component for modeling domain concepts with immutable, type-safe objects. Encapsulate validation and formatting, reduce primitive obsession, and bring clarity and safety to your codebase.
Install with Composer and pin the version, since the package is archived:
composer require serendipity_hq/component-value-objects
Look first at:
src/ — one directory per value object (Email/, Url/, Address/, Money/, PhoneNumber/, Hostname/, Percentage/, TaxRate/, etc.). Each contains the VO plus an interface.src/ValueObjectInterface.php, SimpleValueObjectInterface.php, CompositeValueObjectInterface.php — the contract every class honours (__toString(), get*() getters, validation).src/Converter/ — StringConverter, JsonConverter, ArrayConverter; these are how you turn a VO into something you can persist or ship over the wire.README.md — the author's own examples are the fastest reference for array key names per VO.The canonical first use case is wrapping an email address so it can never be an invalid string in your domain:
use SerendipityHQ\Component\ValueObjects\Email\Email;
$email = new Email(['email' => '[email protected]']);
echo $email->getEmail(); // [email protected]
echo (string) $email; // [email protected]
That is the whole mental model: constructor takes an array of named parameters; validation runs immediately; getters return primitives; converters emit serialised forms. Composite VOs (Address, Money, Person) follow the same shape with more keys:
use SerendipityHQ\Component\ValueObjects\Address\Address;
$address = new Address([
'streetAddress' => 'Via Roma 1',
'postalCode' => '00100',
'city' => 'Roma',
'country' => 'IT',
]);
Array-shape construction + DTOs. Build VOs inside DTOs or action classes, not inside controllers. A CreateCustomerData object that holds Email, PhoneNumber, and Address gives you a validated boundary between HTTP and Eloquent.
Validate in the FormRequest, construct the VO in the DTO. The package throws InvalidArgumentException on bad input, so you want that failure to happen in one place (the mapping layer) rather than scattered through model accessors.
Custom Eloquent casts. This is the highest-leverage integration for day-to-day work — the VO lives in memory, the string lives in the database:
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use SerendipityHQ\Component\ValueObjects\Email\Email;
class EmailCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?Email
{
return $value === null ? null : new Email(['email' => $value]);
}
public function set($model, string $key, $value, array $attributes): ?string
{
if ($value === null) { return null; }
$vo = $value instanceof Email ? $value : new Email(['email' => $value]);
return $vo->getEmail();
}
}
Register with protected $casts = ['email' => EmailCast::class];. Repeat for Url, PhoneNumber, Hostname — the pattern is identical.
Comparisons and de-duplication. Use the comparable interface instead of comparing strings:
if ($existing->equals($incoming)) { /* same address */ }
Serialisation via converters. For API resources, don't reach into getters individually — let the converter flatten composite VOs:
use SerendipityHQ\Component\ValueObjects\Converter\StringConverter;
use SerendipityHQ\Component\ValueObjects\Converter\JsonConverter;
$emailString = (new StringConverter($email))->convert();
$addressJson = (new JsonConverter($address))->convert();
Money / Price / TaxRate as a cluster. These VOs are designed to be composed: a Price carries an amount, a currency, and a TaxRate; a Money is the bare amount + currency. Model your pricing domain around that composition rather than reinventing it.
Extension point for your own VOs. Subclass SimpleValueObject (or CompositeValueObject), implement the matching interface, and override validate(). You get string conversion and comparison traits for free.
"repositories": [{ "type": "vcs", "url": "..." }] in composer.json) before you depend on it heavily. Check composer.json for the PHP constraint — the code predates enums, readonly properties, and attributes.new Email('[email protected]') will not work; it must be new Email(['email' => '[email protected]']). Missing required keys throw immediately — there are no defaults for the essential fields.new is a validation checkpoint. Wrap user-supplied data in try/catch (InvalidArgumentException $e) and translate to a 422. Do not construct VOs inside get() accessors without a try/catch, because a legacy bad row will blow up a list view.__toString() may differ from your input. VOs normalise (e.g. trimming, lower-casing, standardising phone numbers). Always persist the VO's own string form, not the request payload, or you'll churn the DB on every save.PhoneNumber typically relies on libphonenumber-for-php and a default region — supply defaultRegion in the constructor array or validate nationally and hope. Don't assume it accepts free-form strings.amount key is major or minor units before trusting it; getting this wrong is a silent 100× bug. Do the same for Percentage — string "10" vs int 10 may mean 10% vs 1000%.JsonSerializable. json_encode($email) will not do what you expect. Use JsonConverter, or implement JsonSerializable in a small wrapper.equals() is not ===. It compares normalised comparable values, which is the point — but it will throw if you feed it the wrong type. Guard before calling.get(), so historical rows that would fail validation today will still throw when your cast constructs the VO. Consider returning null for obviously malformed values, or backfill the data.where('email', $email) relies on __toString(), which is implicit magic. Use $email->getEmail() explicitly so static analysis and future refactors stay honest.getEmail()) and the string form — that pair is the contract. For composite VOs, assert on the converter output so changes to the internal array shape surface in one place.How can I help you explore Laravel packages today?