A custom cast is the only place a value object belongs

Converting a column to a value object in an accessor works until something writes to the model, at which point there are two representations and one of them is wrong.

final class AsMoney implements CastsAttributes
{
    public function get($model, $key, $value, array $attributes): ?Money
    {
        return $value === null
            ? null
            : new Money((int) $value, $attributes['currency']);
    }

    public function set($model, $key, $value, array $attributes): array
    {
        return [$key => $value->cents(), 'currency' => $value->currency()];
    }
}

A cast covers both directions and the accessor covers one, which is the whole argument. Returning raw column values from set is what keeps the dirty check working — it compares the raw attributes, so returning an object marks the model dirty on every assignment even when nothing changed. Casting a column that feeds two columns, as here with the currency, is supported and is the case that makes casts worth the file.