Why Custom Casts Beat Accessors and Mutators
Getters and setters scattered across a model are hard to test in isolation and impossible to reuse across models. Laravel's CastsAttributes contract gives you a first-class way to encapsulate that logic into a dedicated class, keep your models thin, and make the transformation testable on its own.
This article covers three practical cast patterns: a value-object cast, a composite-column cast, and an encrypted-JSON cast.
1. Value-Object Cast
Suppose you have a Money value object that wraps an integer amount and a currency string.
// app/Values/Money.php
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {}
public function format(): string
{
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
The cast serialises to a single JSON column:
// app/Casts/MoneyCast.php
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
{
$data = json_decode($value, true);
return new Money($data['amount'], $data['currency']);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! $value instanceof Money) {
throw new \InvalidArgumentException('Expected Money instance.');
}
return json_encode(['amount' => $value->amount, 'currency' => $value->currency]);
}
}
Usage on the model:
protected $casts = [
'price' => MoneyCast::class,
];
2. Composite-Column Cast
Sometimes a logical concept spans multiple physical columns — for example, a DateRange stored as starts_at and ends_at. Implement CastsAttributes and return multiple keys from set():
class DateRangeCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): DateRange
{
return new DateRange(
Carbon::parse($attributes['starts_at']),
Carbon::parse($attributes['ends_at']),
);
}
/** @return array<string, mixed> */
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [
'starts_at' => $value->start->toDateTimeString(),
'ends_at' => $value->end->toDateTimeString(),
];
}
}
When set() returns an array, Eloquent merges those keys directly into the attributes bag — no extra columns needed in $casts.
Gotcha: the
$keypassed toget()is whatever key you registered in$casts. For composite casts, that key is virtual; the real columns live in$attributes. Read from$attributes, not$value.
3. Encrypted JSON Cast with Constructor Arguments
Casts accept constructor arguments via the colon syntax. Here's an encrypted cast that accepts a cipher name:
class EncryptedJson implements CastsAttributes
{
public function __construct(private string $cipher = 'AES-256-CBC') {}
public function get(Model $model, string $key, mixed $value, array $attributes): array
{
return json_decode(decrypt($value), true);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return encrypt(json_encode($value));
}
}
Register it with an argument:
protected $casts = [
'metadata' => EncryptedJson::class . ':AES-128-CBC',
];
Laravel resolves the constructor automatically, passing the string after the colon as the first argument.
Testing Casts in Isolation
Because a cast is a plain PHP class, you can unit-test it without a database:
it('round-trips a Money value object', function () {
$cast = new MoneyCast();
$model = new class extends \Illuminate\Database\Eloquent\Model {};
$json = $cast->set($model, 'price', new Money(1999, 'USD'), []);
$back = $cast->get($model, 'price', $json, []);
expect($back->amount)->toBe(1999)
->and($back->currency)->toBe('USD');
});
No factories, no migrations, no HTTP overhead.
Takeaways
CastsAttributesreplaces accessor/mutator pairs with a reusable, testable class.- Return an
arrayfromset()to hydrate multiple physical columns from one logical cast. - Pass constructor arguments with the
ClassName:argcolon syntax. - Casts are plain PHP — unit-test them without touching the database.
- Combine with
readonlyvalue objects for immutable domain primitives that are impossible to corrupt.