Skip to content
Muhammet Şafak
tr
Asked by: Sıla Answered:

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.

  1. 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.

  2. Keep storage as integer cents. In set() return the int cents to store; in get() build Money::fromCents($value). Because the model always hands you a Money, the cents representation never leaks out and the rest of the codebase never sees /100.

  3. The cast handles the multi-column case cleanly. If amount and currency are two columns, the cast can read and write both via the $attributes array in get()/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.

  4. 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.

  5. 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 a cents() on the VO and use it in queries.

  6. Don’t forget the edge cases. Handle null in get()/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 via castUsing.

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

Tags: #laravel#eloquent#casts
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind