Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Value Object Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

  1. Read the source first. Open 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.
  2. Read the tests (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).
  3. Install and pin.
    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.
  4. First use case: wrap one identifier, not everything. Pick the single value in your domain that is currently a bare 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.

Implementation Patterns

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:

  • Custom cast (Laravel 7+). Write AsValueObject implements CastsAttributes and map column ↔ object in one place. This is the cleanest integration and keeps your models thin.
  • Accessor/mutator pair on the model (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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings