Why Custom Casts Beat Accessor/Mutator Pairs
Before Laravel 8 introduced CastsAttributes, the standard approach was a getXAttribute / setXAttribute pair. That works, but it scatters transformation logic across two methods, makes the intent implicit, and is impossible to reuse across models. A custom cast is a self-contained class: one place to read, one place to write, and trivially injectable into any model.
The Interface Contract
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/**
* @implements CastsAttributes<Money, array{amount:int,currency:string}>
*/
class MoneyCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
if (is_null($value)) {
return Money::zero();
}
$decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
return new Money(
amount: $decoded['amount'],
currency: Currency::from($decoded['currency'])
);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if ($value instanceof Money) {
return json_encode([
'amount' => $value->amount,
'currency' => $value->currency->value,
], JSON_THROW_ON_ERROR);
}
throw new \InvalidArgumentException('Value must be a Money instance.');
}
}
The generic annotation on the @implements docblock is picked up by PHPStan and Psalm, giving you typed attribute access without any extra stubs.
Registering the Cast
class Order extends Model
{
protected $casts = [
'total' => MoneyCast::class,
];
}
Now $order->total is always a Money object. No defensive instanceof checks in your service layer.
Parameterised Casts
Sometimes you need the cast to behave differently per attribute — for example, rounding precision. Pass arguments via the colon syntax:
protected $casts = [
'price' => MoneyCast::class . ':2',
'tax_rate' => MoneyCast::class . ':4',
];
Receive them in the constructor:
class MoneyCast implements CastsAttributes
{
public function __construct(protected int $precision = 2) {}
// get / set use $this->precision
}
Inbound-Only Casts
If you only need to transform on write (e.g., hashing a PIN), implement CastsInboundAttributes instead. It has only a set method, which makes the intent explicit and prevents accidental reads of the raw value.
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
class HashedPinCast implements CastsInboundAttributes
{
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return bcrypt((string) $value);
}
}
Testing Your Cast in Isolation
Because the 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 Order();
$json = $cast->set($model, 'total', new Money(1999, Currency::GBP), []);
$back = $cast->get($model, 'total', $json, []);
expect($back->amount)->toBe(1999)
->and($back->currency)->toBe(Currency::GBP);
});
No factories, no migrations, no HTTP — just fast, focused assertions.
Handling Null Gracefully
Eloquent passes null to get when the column is NULL. Always decide explicitly: return a null object, throw, or return null and mark the property nullable in your type hint. Returning a null object (like Money::zero()) is usually the safest choice for arithmetic-heavy domains.
When Not to Use a Cast
Casts are evaluated on every attribute access. If your value object is expensive to construct (e.g., it calls a service or parses a large blob), consider lazy construction or caching the result in a model property instead.
Takeaways
CastsAttributesgives you a single, reusable class for bidirectional transformation — no more scattered accessor/mutator pairs.- Parameterised casts via the colon syntax keep one class flexible across multiple attributes.
- Use
CastsInboundAttributesfor write-only transformations like hashing. - Casts are plain PHP classes: unit-test them without a database for fast feedback loops.
- Always handle
nullexplicitly to avoid silent type errors downstream. - Avoid expensive operations inside
get; casts run on every property read.