Eloquent Custom Casts: Encapsulating Value Objects Without the Bloat
#laravel #eloquent #ddd #value-objects #casts

Eloquent Custom Casts: Encapsulating Value Objects Without the Bloat

1 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 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.
Q02 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.
Q03 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.

Continue reading

More Articles

View all