Should I bind a Money value object to Eloquent with a custom cast or with accessors/mutators?
Question
I store amounts as integer cents in the database (like `price_cents`). I want a `Money` value object on my models, but I don't want the cents representation leaking everywhere — I don't want to keep doing `/100` in Blade, in the service layer, in tests. In Laravel, is it more correct to bind this with a custom cast (`CastsAttributes`) or with classic accessors/mutators? I might later add a `currency` column next to the amount; how does that choice affect that case?
Answer
Short answer: use a custom cast (a class implementing CastsAttributes). Casts are designed precisely for value object ↔ column mapping; accessors/mutators are the older, clunkier tool for a single-column VO.
-
A custom cast is purpose-built for this. You write one cast class with
get()/set()and reuse it across every model and every column. Accessors are per-model, per-attribute methods; you’d rewrite the same Money logic on every model. -
Keep storage as integer cents. In
set()return the int cents to store; inget()buildMoney::fromCents($value). Because the model always hands you aMoney, the cents representation never leaks out and the rest of the codebase never sees/100. -
The cast handles the multi-column case cleanly. If amount and currency are two columns, the cast can read and write both via the
$attributesarray inget()/set().set()can return an array like['amount_cents' => ..., 'currency' => ...]instead of a single attribute. Doing that with accessors/mutators — mapping two columns into one object and writing back to two — gets ugly. If you’re adding currency later, that alone makes the cast the choice. -
Keep Money immutable. Make the VO
readonly; keep money arithmetic (avoiding floats) and formatting there — not in Blade. Equality (equals) and currency-mismatch checks are the VO’s job, not the cast’s. -
Remember the cast doesn’t apply to the query builder. The cast only runs on attribute get/set on the model; a query like
->where('price', $money)doesn’t go through it. In filters you still pass raw cents (->where('price', $money->cents())). This is where people trip most often; expose acents()on the VO and use it in queries. -
Don’t forget the edge cases. Handle
nullinget()/set()for a nullable column, and keep the cast cheap since it runs on every hydration. Non-constant parameters (like a default currency) can be passed to the cast viacastUsing.
The single-column core looks like this:
/** @implements CastsAttributes<Money, Money> */
final class MoneyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?Money
{
return $value === null ? null : Money::fromCents((int) $value);
}
public function set($model, string $key, $value, array $attributes): ?int
{
return $value?->cents();
}
}
// on the model:
protected function casts(): array
{
return ['price' => MoneyCast::class];
}
Bottom line: personally I’d set up the trio of a custom cast + integer cents in the DB + an immutable Money VO, and manage currency as a second column from within that same cast. I’d keep accessors/mutators only for a trivial, model-specific computed field that doesn’t even touch the VO. That way the cents representation stays behind the model, and every other layer only ever sees Money.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.