Back to Blog

Value Objects: The Pattern Your Laravel Needs

- 5 min read

How many bugs have you fixed because someone passed an email where a user ID was expected? Or mixed up cents and dollars? Or used an invalid status string? Value Objects eliminate these bugs entirely.

The Problem with Primitives

Consider this common Laravel code:

function createUser(string $email, string $name, int $age): User
{
    // Is this email valid?
    // Is name empty?
    // Is age negative?
}

Every function that receives these values must validate them. Or trust that someone else did. The type system only tells us "it's a string" - not "it's a valid email".

Worse, nothing prevents this:

// Oops! Name and email swapped
createUser($name, $email, $age);

Value Objects to the Rescue

A Value Object wraps a primitive and guarantees its validity:

final readonly class Email
{
    public function __construct(public string $value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email: $value");
        }
    }

    public function domain(): string
    {
        return substr($this->value, strpos($this->value, '@') + 1);
    }

    public function equals(Email $other): bool
    {
        return $this->value === $other->value;
    }
}

Now your function signature tells the whole story:

function createUser(Email $email, Name $name, Age $age): User
{
    // All values are guaranteed valid
    // Can't accidentally swap email and name - different types!
}

Common Value Objects

Money

final readonly class Money
{
    public function __construct(
        private int $cents,
        private Currency $currency
    ) {
        if ($cents < 0) {
            throw new InvalidArgumentException('Money cannot be negative');
        }
    }

    public static function fromDollars(float $dollars, Currency $currency): self
    {
        return new self((int)($dollars * 100), $currency);
    }

    public function add(Money $other): self
    {
        if (!$this->currency->equals($other->currency)) {
            throw new CurrencyMismatchException();
        }
        return new self($this->cents + $other->cents, $this->currency);
    }

    public function format(): string
    {
        return $this->currency->symbol() . number_format($this->cents / 100, 2);
    }
}

DateRange

final readonly class DateRange
{
    public function __construct(
        public CarbonImmutable $start,
        public CarbonImmutable $end
    ) {
        if ($end->isBefore($start)) {
            throw new InvalidArgumentException('End must be after start');
        }
    }

    public function contains(CarbonImmutable $date): bool
    {
        return $date->between($this->start, $this->end);
    }

    public function overlaps(DateRange $other): bool
    {
        return $this->start->lte($other->end)
            && $this->end->gte($other->start);
    }

    public function days(): int
    {
        return $this->start->diffInDays($this->end);
    }
}

Address

final readonly class Address
{
    public function __construct(
        public string $street,
        public string $city,
        public string $postalCode,
        public Country $country
    ) {
        if (empty(trim($street))) {
            throw new InvalidArgumentException('Street cannot be empty');
        }
        // More validation...
    }

    public function format(): string
    {
        return implode(', ', [
            $this->street,
            $this->city,
            $this->postalCode,
            $this->country->name()
        ]);
    }
}

Using Value Objects with Eloquent

Laravel's custom casts make Value Objects work seamlessly with Eloquent:

class EmailCast implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): ?Email
    {
        return $value ? new Email($value) : null;
    }

    public function set($model, $key, $value, $attributes): ?string
    {
        return $value?->value;
    }
}

class User extends Model
{
    protected $casts = [
        'email' => EmailCast::class,
    ];
}

// Now this works:
$user->email = new Email('john@example.com');
$user->save();

echo $user->email->domain(); // "example.com"

Why AI Loves Value Objects

When AI coding assistants see your code, Value Objects give them critical context:

// AI doesn't know what these strings mean
function processPayment(string $amount, string $currency): void

// AI understands exactly what's expected
function processPayment(Money $amount): void

The AI can't accidentally suggest passing a user ID where money is expected. The types make the intent explicit.

Start Today

You don't need to convert your entire codebase. Start with:

  1. Email - the most common invalid primitive
  2. Money - if you handle payments
  3. UserId - prevent mixing up IDs
  4. DateRange - for booking/scheduling systems

Each Value Object you add prevents a category of bugs forever.

Want more patterns?

Chapter 6 (Value Objects) of Pragmatic DDD with Laravel covers 15+ real-world Value Objects with complete implementations and Eloquent integration.

Get the book