Skip to content

Eloquent Custom Casts: Encapsulating Value Objects Without the Bloat

Custom Eloquent casts let you bind rich value objects directly to model attributes. This article shows how to build clean, testable casts that enforce domain rules at the boundary between your database and PHP.

Published · Updated · 1 min read

Eloquent Custom Casts: Encapsulating Value Objects Without the Bloat

Why Custom Casts Belong in Your Toolkit

Laravel ships with a solid set of built-in casts — integer, boolean, encrypted, AsCollection — but the moment your domain has a concept like Money, EmailAddress, or Coordinate, you're left choosing between anemic strings in your models or a pile of accessor/mutator pairs.

Custom casts via CastsAttributes give you a third, cleaner option: a dedicated class that owns the serialisation contract and enforces invariants every time the attribute is read or written.


Building a Money Value Object Cast

Start with the value object itself. Keep it immutable and self-validating.

<?php

namespace App\Values;

final class Money
{
    public function __construct(
        public readonly int $amount,   // stored in minor units (cents)
        public readonly string $currency,
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative.');
        }

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

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

Now implement the cast. The contract is two methods: get (hydrate from raw DB value) and set (dehydrate back to storable form).

<?php

namespace App\Casts;

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

/**
 * Expects two columns: {attribute}_amount (int) and {attribute}_currency (string).
 */
class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): Money
    {
        return new Money(
            amount: (int) $attributes["{$key}_amount"],
            currency: $attributes["{$key}_currency"],
        );
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if (!$value instanceof Money) {
            throw new \InvalidArgumentException('Value must be a Money instance.');
        }

        return [
            "{$key}_amount"   => $value->amount,
            "{$key}_currency" => $value->currency,
        ];
    }
}

Register it on your model:

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

Your migration keeps two physical columns (price_amount, price_currency), but your application code only ever sees a Money object:

$order = Order::find(1);
echo $order->price->format(); // "29.99 USD"

$order->price = new Money(4999, 'EUR');
$order->save();

Parameterised Casts

Sometimes you need to pass configuration to a cast — for example, a rounding scale for a Decimal type. Implement CastsAttributes and accept constructor arguments:

class DecimalCast implements CastsAttributes
{
    public function __construct(private int $scale = 2) {}

    public function get(Model $model, string $key, mixed $value, array $attributes): string
    {
        return number_format((float) $value, $this->scale, '.', '');
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        return round((float) $value, $this->scale);
    }
}

Use it with a colon-delimited argument:

protected $casts = [
    'tax_rate' => DecimalCast::class . ':4',
];

Laravel resolves the constructor argument automatically.


Testing the Cast in Isolation

Because the cast is a plain class, you can unit-test it without touching the database:

it('hydrates a Money value object from raw attributes', function () {
    $cast  = new MoneyCast();
    $model = new Order();

    $money = $cast->get($model, 'price', null, [
        'price_amount'   => 1999,
        'price_currency' => 'GBP',
    ]);

    expect($money)->toBeInstanceOf(Money::class)
        ->and($money->amount)->toBe(1999)
        ->and($money->currency)->toBe('GBP');
});

it('rejects a negative amount', function () {
    new Money(-1, 'USD');
})->throws(\InvalidArgumentException::class);

No factories, no database — fast, focused feedback.


Pitfalls to Avoid

  • Don't reach into $model inside get/set unless absolutely necessary. It creates hidden coupling and makes the cast untestable in isolation.
  • Avoid lazy DB queries inside a cast. If you need related data, load it eagerly before the cast runs.
  • Multi-column casts must return an array from set. Returning a scalar silently overwrites only the primary column.
  • Null handling is your responsibility. Guard against null in get if the column is nullable.

Takeaways

  • CastsAttributes is the cleanest way to bind a value object to an Eloquent attribute.
  • Multi-column casts return an associative array from set; Laravel merges it into the dirty attributes automatically.
  • Parameterised casts accept constructor arguments via the ClassName:arg syntax in $casts.
  • Keep invariant enforcement inside the value object, not the cast — the cast is only a serialisation adapter.
  • Unit-test casts independently of the database for fast, reliable feedback.

Frequently asked questions

Can a custom cast handle nullable columns?

Yes. In your `get` method, check whether the relevant raw attribute is null and return null (or a sensible default) before constructing the value object. In `set`, guard against a null value being passed and return null columns accordingly.

Does using a multi-column cast affect Eloquent's dirty-checking?

Eloquent merges the array returned by `set` into the model's attributes, so dirty-checking works correctly for each physical column. Both columns will appear in the UPDATE statement if either changes.

Should I implement CastsInboundAttributes instead of CastsAttributes?

`CastsInboundAttributes` is for write-only casts (e.g. hashing a password on set). If you need to read the value back as a value object, implement the full `CastsAttributes` interface.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project