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
$modelinsideget/setunless 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
nullingetif the column is nullable.
Takeaways
CastsAttributesis 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:argsyntax 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.