PHP Readonly Classes as Value Objects in Laravel

PHP readonly classes make immutable value objects concise in Laravel. Replace primitive strings and integers with Money, EmailAddress, and Eloquent casts.

Steven Richardson
Steven Richardson
· 6 min read

I keep seeing processPayment(int $amount, string $currency, string $email) in production codebases. Is $amount in pence or pounds? Is $email validated before it reaches this method? Usually nobody knows. This is primitive obsession — and PHP readonly classes make the fix clean.

PHP readonly classes and the value object pattern#

A value object has two rules: equality by value (not by reference), and immutability. PHP 8.2 introduced readonly classes, which enforce the second rule automatically — every declared property on a readonly class becomes implicitly readonly. No need to annotate each one individually.

Before PHP 8.2, an immutable class meant boilerplate on every property:

// Pre-8.2 — manual immutability
class Money
{
    public function __construct(
        private readonly int $amountInPence,
        private readonly string $currency,
    ) {}
}

With a readonly class, you declare it once:

// PHP 8.2+
readonly class Money
{
    public function __construct(
        public int $amountInPence,
        public string $currency,
    ) {}
}

The class modifier covers every property automatically. Two requirements to keep in mind: every property must have a type declaration, and static properties are not allowed. Both constraints make sense for value objects anyway — if you need a static property, you probably don't have a value object.

Building a practical Money value object#

Here's a Money value object I use for GBP/USD billing:

namespace App\ValueObjects;

readonly class Money
{
    public function __construct(
        public int $amountInPence,
        public string $currency,
    ) {
        if ($this->amountInPence < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative.');
        }

        if (! in_array($this->currency, ['GBP', 'USD', 'EUR'], strict: true)) {
            throw new \InvalidArgumentException("Unsupported currency: {$this->currency}");
        }
    }

    public static function ofPounds(float $pounds, string $currency = 'GBP'): self
    {
        return new self((int) round($pounds * 100), $currency);
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \LogicException('Cannot add different currencies.');
        }

        // Value objects are immutable — arithmetic returns a new instance
        return new self($this->amountInPence + $other->amountInPence, $this->currency);
    }

    public function formatted(): string
    {
        return number_format($this->amountInPence / 100, 2) . ' ' . $this->currency;
    }

    public function equals(self $other): bool
    {
        return $this->amountInPence === $other->amountInPence
            && $this->currency === $other->currency;
    }
}

Validation lives in the constructor. Every Money instance is guaranteed valid at the point of creation — you can't construct an invalid one. Arithmetic returns a new instance rather than mutating the existing one. That's the core of the pattern: "modification" means creating a new value, not changing the old one.

$price  = Money::ofPounds(29.99);
$tax    = Money::ofPounds(5.99);
$total  = $price->add($tax);

echo $total->formatted(); // "35.98 GBP"

An EmailAddress value object#

Email addresses are the most abused primitive in Laravel apps. They get passed around as raw strings, validated in some places but not others:

namespace App\ValueObjects;

readonly class EmailAddress
{
    public string $value;

    public function __construct(string $email)
    {
        $normalised = mb_strtolower(trim($email));

        if (! filter_var($normalised, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Invalid email: {$email}");
        }

        $this->value = $normalised;
    }

    public function __toString(): string
    {
        return $this->value;
    }

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

The address is normalised (lowercased, trimmed) and validated in the constructor. Anywhere you type-hint EmailAddress, you have a guarantee the value is already valid. No more defensive filter_var() checks scattered across service classes.

Eloquent casts with CastsAttributes#

To use Money in an Eloquent model, implement CastsAttributes. In Laravel 12 the interface uses fully-typed parameters:

namespace App\Casts;

use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money(
            (int) $value,
            $attributes['currency'] ?? 'GBP',
        );
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if (! $value instanceof Money) {
            $value = new Money((int) $value, $attributes['currency'] ?? 'GBP');
        }

        return [
            $key       => $value->amountInPence, // stored as integer pence
            'currency' => $value->currency,
        ];
    }
}

Wire it up in the model:

class Order extends Model
{
    protected $casts = [
        'price' => MoneyCast::class,
    ];
}

// Reading — returns a Money instance
echo $order->price->formatted(); // "29.99 GBP"

// Writing — set() decomposes back to primitives before save
$order->price = Money::ofPounds(49.99);
$order->save();

This assumes your orders table has both a price column (integer, pence) and a currency column (string). The cast maps those two DB columns to a single Money object on read, and decomposes back on write.

Using php readonly classes in Form Requests#

Form Requests are a good place to convert validated input into value objects before it reaches your controller. I use prepareForValidation() to normalise, and a typed helper method to return the cast value:

class ProcessPaymentRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'amount_pence' => ['required', 'integer', 'min:1'],
            'currency'     => ['required', 'string', 'in:GBP,USD,EUR'],
            'email'        => ['required', 'email'],
        ];
    }

    public function money(): Money
    {
        return new Money(
            $this->validated('amount_pence'),
            $this->validated('currency'),
        );
    }

    public function emailAddress(): EmailAddress
    {
        return new EmailAddress($this->validated('email'));
    }
}

// In the controller — no primitives in sight
public function store(ProcessPaymentRequest $request): Response
{
    $this->paymentService->charge($request->money(), $request->emailAddress());
}

I prefer constructing value objects after validated() rather than inside prepareForValidation(). It keeps validation rules doing validation, and value object construction separate.

Gotchas and Edge Cases#

"Modifying" a readonly value object — readonly properties cannot be reassigned after construction. To produce a variant, return a new instance:

// ✅ Correct — return a new instance
public function withCurrency(string $currency): self
{
    return new self($this->amountInPence, $currency);
}

// ❌ This will throw — readonly property cannot be overwritten
public function withCurrency(string $currency): self
{
    $this->currency = $currency; // Error: Cannot modify readonly property
    return $this;
}

PHP 8.3 cloning — If you need __clone() support, PHP 8.3+ allows reinitialising readonly properties inside __clone(). For simple value objects, new self() is almost always the cleaner option.

PHP 8.4 visibility change — In PHP 8.4, readonly properties changed from implicitly private(set) to protected(set). For value objects this is unlikely to matter since you're not subclassing them, but worth noting if you extend value objects.

JSON serialisationjson_encode() works on readonly objects exactly as it does on regular objects. No special handling required.

When to skip value objects — A UserId readonly class that wraps a plain integer with no validation and no helper methods is noise. Value objects earn their place when the primitive has behaviour: money with currencies, email addresses with normalisation, coordinates with distance helpers. For simple CRUD models with no domain logic, the extra class is not worth it.

Wrapping Up#

PHP readonly classes make the value object pattern the obvious choice over manual immutability boilerplate. Start with a Money or EmailAddress class, pair it with a custom Eloquent cast, and you'll wonder why you were passing raw integers around. If you're already using PHP 8.4 property hooks on your Eloquent models, value objects make a natural complement — the cast handles persistence, and a virtual property hook handles display. See PHP 8.4 property hooks in Laravel models for how those fit together.

For further type-safety improvements in your models, replacing string constants with PHP backed enums follows naturally from the same philosophy — both patterns eliminate primitive obsession from your domain layer. These patterns are part of the complete Laravel developer toolchain I use on every production project.

FAQ#

How do I "update" a readonly value object?

You don't — you create a new instance with the new values. For example, $newMoney = new Money($newAmount, $newCurrency). Or add a helper method: public function withAmount(int $amount): self { return new self($amount, $this->currency); }.

What if I need to clone a value object with one property changed?

PHP 8.5's clone with syntax is perfect for this. Instead of creating new instances manually, use: $updated = clone($money, ['amount' => $newAmount]). See the article on PHP 8.5 clone with.

Can I use json_encode() on a readonly value object?

Yes, json_encode() works on readonly objects exactly as it does on regular objects. If you need custom serialization, implement JsonSerializable and define jsonSerialize().

What's the difference between a readonly class and a readonly property?

A readonly class makes all properties readonly implicitly. A readonly property on a regular class is just that one property. Use readonly class for value objects where all properties should be immutable; use readonly properties when only some fields need immutability.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.