Why Built-in Casts Are Not Enough
Laravel ships with a solid set of primitive casts — integer, boolean, array, encrypted, AsCollection, and friends. They cover 80% of everyday needs. The remaining 20% is where models quietly accumulate logic they should never own: formatting phone numbers, normalising currency, converting units, enforcing invariants.
Custom cast classes move that responsibility to a dedicated type, tested in isolation, reusable across models.
Anatomy of a Custom Cast
A cast class implements CastsAttributes. The contract is simple:
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use App\ValueObjects\Money;
class MoneyCast implements CastsAttributes
{
public function __construct(
private readonly string $currency = 'GBP'
) {}
/** @param int $value raw pence stored in DB */
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
return Money::fromMinorUnits((int) $value, $this->currency);
}
public function set(Model $model, string $key, mixed $value, array $attributes): int
{
if ($value instanceof Money) {
return $value->minorUnits();
}
return (int) $value;
}
}
Declare it on the model using the constructor-argument syntax introduced in Laravel 9:
protected function casts(): array
{
return [
'price' => MoneyCast::class . ':USD',
'tax' => MoneyCast::class, // defaults to GBP
];
}
The model now returns a Money value object from $order->price, and accepts either a Money instance or a raw integer on assignment.
Value Objects as First-Class Citizens
A value object should be immutable and self-validating:
final class Money
{
private function __construct(
private readonly int $minorUnits,
private readonly string $currency,
) {
if ($minorUnits < 0) {
throw new \DomainException('Money cannot be negative.');
}
}
public static function fromMinorUnits(int $units, string $currency): self
{
return new self($units, strtoupper($currency));
}
public function minorUnits(): int { return $this->minorUnits; }
public function currency(): string { return $this->currency; }
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \DomainException('Currency mismatch.');
}
return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
public function format(): string
{
return number_format($this->minorUnits / 100, 2) . ' ' . $this->currency;
}
}
The invariant (>= 0) is enforced at construction time — not scattered across service classes.
Inbound-Only Casts
Sometimes you only need to transform data on the way in — hashing a token, normalising a slug, uppercasing a country code. Implement CastsInboundAttributes instead:
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
class NormaliseCountryCode implements CastsInboundAttributes
{
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return strtoupper(trim((string) $value));
}
}
The get side is intentionally absent — the raw database value is returned as-is. This is perfect for write-time normalisation without the overhead of a full bidirectional cast.
Casting to Multiple Columns
A single value object can map to multiple database columns by returning an array from set:
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [
'amount' => $value->minorUnits(),
'currency' => $value->currency(),
];
}
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
return Money::fromMinorUnits(
(int) $attributes['amount'],
$attributes['currency'],
);
}
Declare the virtual key on the model:
'price' => MoneyCast::class,
Eloquent will merge the returned array into the dirty attributes automatically.
Testing the Cast in Isolation
Because the cast is a plain PHP class, you can test it without booting the framework:
it('converts minor units to a Money value object', function () {
$cast = new MoneyCast('EUR');
$model = new class extends Model {};
$money = $cast->get($model, 'price', 1999, []);
expect($money)->toBeInstanceOf(Money::class)
->and($money->minorUnits())->toBe(1999)
->and($money->currency())->toBe('EUR');
});
it('rejects negative minor units', function () {
expect(fn () => Money::fromMinorUnits(-1, 'EUR'))
->toThrow(\DomainException::class);
});
No database, no HTTP — fast, deterministic, and meaningful.
Key Takeaways
- Use
CastsAttributesfor bidirectional transforms; useCastsInboundAttributeswhen you only need write-time normalisation. - Pass constructor arguments via the
ClassName:arg1,arg2syntax to make casts reusable across currencies, locales, or units. - Return an array from
setto map a single virtual attribute to multiple physical columns. - Keep value objects immutable and self-validating — the cast is just the bridge, not the domain logic.
- Test cast classes as plain PHP; no
RefreshDatabaseneeded.