micro-module/value-object
Immutable, self-validating value objects for PHP and Laravel. Wrap primitives like emails, IDs, and money in typed classes that guard their own invariants, so invalid values can't exist and domain logic stays clean, testable, and free of scattered validation checks.
This is a micro-package (single-digit stars, zero dependents, one release in April 2025), so treat the source as the documentation. Budget ten minutes before writing any code.
composer.json to see the PSR-4 namespace and the PHP version floor, then list src/. Micro value-object libraries typically ship a small set: an abstract/base ValueObject (or SingleValueObject), a handful of scalar wrappers (StringValue, IntValue, BoolValue, FloatValue), an Enum-style base, and an equality contract (equals() / isSameAs()). Read each base class constructor and any assert*/guard* helpers — that is where the validation contract lives.tests/) if present. In micro-packages the tests are the only reliable usage example, and they tell you which behaviours the author actually guarantees (e.g. whether equals() is type-strict, whether __toString() is implemented, whether jsonSerialize() exists).composer require micro-module/value-object:^1.0
Pin to a caret-minor range and commit composer.lock. With no downstream users, a patch release can silently change semantics.string and causes the most bugs — typically UserId, Email, or Money. Subclass the base, validate in the constructor, and pass it through one controller → action → repository path. Do not refactor your whole model layer in the same PR; you want to see how the object behaves at the framework boundaries (Eloquent, queues, JSON) before scaling.A minimal first class, assuming the package exposes a single-value base (adjust names to what src/ actually contains):
final class Email extends StringValue
{
protected function validate(string $value): void
{
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
}
}
The final keyword is doing real work here: immutability in PHP is a convention, not a language guarantee (unless the package's base uses readonly properties and you are on PHP 8.1+), and a non-final subclass can reintroduce mutators.
Constructor validation, static named constructors. Keep the constructor protected-ish and expose intent-revealing factories — Email::fromString($raw), Money::fromCents(1250, 'EUR'). This keeps call sites readable and gives you one place to normalise input (trim, lowercase, strip whitespace) before validation.
Equality via the package contract, not ==. Value objects must never be compared with == or === in domain code — === requires identity, == compares property bags and will happily equate objects of different classes if the base doesn't guard it. Use the shipped equals() (and check in the source whether it enforces get_class($this) === get_class($other); if it doesn't, override it). Wire this into Laravel where comparisons matter: policy checks, unique guards, cache keys.
Persistence: cast at the edges, objects in the core. Keep value objects out of Eloquent attributes. Two options, in order of preference:
AsValueObject implements CastsAttributes and map column ↔ object in one place. This is the cleanest integration and keeps your models thin.getEmailAttribute / setEmailAttribute). Fine for a handful of columns; gets noisy past three or four.For query scoping remember the cast does not apply to where() clauses — you must pass the raw scalar: User::where('email', $email->value()) (or ->toString(), per the package's accessor name).
Composition over proliferation. Build Money from an IntValue amount plus a Currency value object rather than adding an amount() accessor to a god-object. The package's scalars compose well because each is small and validated independently.
Serialisation. Implement JsonSerializable and __toString() on your subclasses. __toString() gets you route-model binding and Blade output for free; jsonSerialize() gets you clean API responses. For queues, prefer serialising to a scalar in the job payload rather than passing the object — SerializesModels will attempt to restore it and you'll depend on the package's __serialize/__unserialize, which may not exist.
Testing. Assert behaviour, not representation: test that an invalid value throws, that two instance of the same value are equals(), and that normalisation is applied (Email::fromString(' [email protected] ') equals Email::fromString('[email protected]')). Add one thin test per subclass — these are cheap and they protect you if the base class changes under you.
Adoption risk is the headline gotcha. One star, zero dependents, a single release. There is no Stack Overflow corpus, no third-party blog posts, and no guarantee the API survives to 2.0. Mitigations: keep the package behind your own thin interfaces where the value objects cross module boundaries, and keep your subclasses small enough to swap for a hand-rolled base in an afternoon. The whole library is likely a few hundred lines — vendoring it is a legitimate option.
Immutability is borrowed, not enforced. Check whether the base uses readonly or private properties. If properties are protected (common in older micro-libraries), a subclass in your codebase can mutate them and your "immutable" object will be mutable in practice. Always declare your own subclasses final and never add setters.
Equality that ignores class. Many simple bases implement equals() purely as $this->value === $other->value. Two different value objects wrapping the same string then compare equal. If your domain relies on type discrimination, override equals() in your subclass to check instanceof self first, or at minimum document the behaviour.
Exceptions vs. framework validation. The library will throw plain \InvalidArgumentException (or a package-specific exception) — these are not ValidationExceptions, so they render as HTTP 500 in Laravel unless caught. Either catch in a FormRequest/prepareForValidation step and convert, or let the exception be the last line of defence and validate the raw input first. Don't let raw user input reach the constructor on a request path without a try/catch.
Casting and null. Eloquent casts receive null for nullable columns. Your cast's get() must return null rather than constructing an object from null, or every nullable email column will explode on hydration. Likewise set() should pass null through untouched.
Config quirks are minimal — which is a feature. Expect no config file, no service provider, no facade. That means nothing to php artisan vendor:publish and nothing to cache. If the package does ship a provider (check composer.json extra.laravel), register it explicitly; Laravel's package discovery may not pick up a package this small if the extra block is malformed — a classic cause of "class not found" on a package that otherwise works in plain PHP.
Debugging tip: when an object "doesn't match" in a comparison, var_dump both sides and check class before value. Nine times out of ten the failure is a === where you meant equals(), or a value object compared against the raw string it wraps.
Extension points to prefer: the base's validation hook (override it, don't reimplement the constructor), equals() (override for strict typing), and __toString()/jsonSerialize() (implement for framework interop). Avoid overriding the constructor unless you must — the base may do normalisation you'd otherwise duplicate.
How can I help you explore Laravel packages today?