Custom casts, and the value object that fits the ORM

Money was stored as integer cents and a currency code, and every model that had a monetary column carried an accessor and a mutator to convert them. Eleven models, twenty-two methods, and three of them subtly different because they had been copied at different times.

The symptom

// in Order, and in Invoice, and in Refund, and in eight others
public function getTotalAttribute($value): Money
{
    return new Money((int) $value, $this->attributes['currency']);
}

public function setTotalAttribute(Money $total): void
{
    $this->attributes['total']    = $total->cents();
    $this->attributes['currency'] = $total->currency();
}
$ grep -rn 'function getTotalAttribute|function setTotalAttribute' app/Models/ | wc -l
22

# and the three that had drifted
$ grep -rn 'new Money' app/Models/ | grep -v "attributes['currency']"
app/Models/Refund.php:  return new Money((int) $value, 'GBP');   ← hardcoded
app/Models/Credit.php:  return new Money($value, $this->currency); ← no cast
app/Models/Fee.php:     return $value ? new Money(...) : null;    ← nullable

The hardcoded currency was a bug that had been in production for eight months and affected exactly one customer, who was invoiced in euros and refunded in pounds. Nothing had caught it because there was no single implementation to test.

Why it happens

The $casts array took strings — array, datetime, boolean — so anything the framework did not know about had to be an accessor and a mutator. Those are per model by construction, so a type used in eleven models is eleven copies with no mechanism keeping them in step.

The pair also has to be written together or the model reads a Money and writes an integer, which produces a bug that only appears on the second save. That is the class of error the drift above represents.

The fix

A CastsAttributes class, written once

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

    public function set($model, string $key, $value, array $attributes): array
    {
        if ($value === null) {
            return [$key => null];
        }

        if (! $value instanceof Money) {
            throw new InvalidArgumentException('expected Money');
        }

        return [$key => $value->cents(), 'currency' => $value->currency()];
    }
}

// protected $casts = ['total' => AsMoney::class];

Returning an array from set is what lets one cast span two columns, which is the case the accessor pair handled worst — the mutator had to know about a sibling column and nothing declared that dependency. Here it is visible in one place and the type check makes assigning a raw integer an immediate error rather than a silent corruption.

The $attributes parameter gives access to the other raw columns, which is what makes the currency lookup possible. That dependency is invisible from the $casts array, so a model casting total without a currency column fails at read time — which is worth a comment next to the cast.

Castable, when the value object should own its own casting

final class Money implements Castable
{
    public static function castUsing(array $arguments): string
    {
        return AsMoney::class;
    }
}

// which makes the cast declaration read better
protected $casts = [
    'total'    => Money::class,
    'discount' => Money::class,
];

The indirection buys one thing: the $casts array names the domain type rather than an infrastructure class, so a reader sees what the column is rather than how it is converted. The $arguments parameter carries anything after a colon in the cast string, which is how a parameterised cast works.

protected $casts = ['total' => Money::class . ':GBP'];

// castUsing(['GBP']) — so the cast can be configured per column
public static function castUsing(array $arguments): CastsAttributes
{
    return new AsMoney($arguments[0] ?? null);
}

Inbound-only casts, for a field written and never read back

final class AsHash implements CastsInboundAttributes
{
    public function set($model, string $key, $value, array $attributes): array
    {
        return [$key => password_hash($value, PASSWORD_DEFAULT)];
    }
}

// $user->password = 'plaintext';   → hashed on the way in
// $user->password                  → the hash, unchanged

There is no get because there is nothing to convert on the way out, and the interface enforces that — which is more honest than a two-way cast whose get returns the value untouched. It is the right tool for a hash, an encrypted column or anything normalised on write.

The comparison that decides whether a cast has changed

A cast returning an object introduces a question the string casts never had: how does Eloquent know the attribute is dirty?

$order->total = new Money(4900, 'GBP');
$order->isDirty('total');

// FALSE for an identical value — because set() returns RAW
// column values, and those are what the dirty check compares.
// a cast returning an OBJECT from set() would compare by
// identity and mark everything dirty on every assignment.

Because set returns raw column values, the dirty check compares those rather than the objects — so assigning an equal Money produces no update, which is what you want and is not obvious from the API. A cast that returned an object from set would compare by identity and mark everything dirty on every assignment.

The objects themselves are cached per model instance, so reading $order->total twice returns the same instance — which matters if the value object is mutable, and is the strongest argument for making it immutable.

Verifying it worked

$ grep -rn 'function get.*Attribute|function set.*Attribute' app/Models/ | wc -l
0

$ php artisan test --filter Money
 ✓ it reads the currency from the sibling column
 ✓ it refuses a raw integer
 ✓ it round-trips through save and refresh
 ✓ null stays null in both directions
 ✓ an equal value does not mark the model dirty

$ php artisan test
Tests: 1,284 passed

# and the bug that started this
$ php artisan tinker
>>> Refund::find(4102)->amount->currency();
=> "EUR"      # was "GBP", hardcoded, for eight months

Five tests covering one class replaced twenty-two untested methods, and the round-trip test is the one that matters most — save, refresh from the database, and assert the object is equal — because it exercises both directions and the raw storage in between.

What this costs

A class per type and a layer people have to know about. A developer reading a model sees 'total' => Money::class and has to open two more files to understand what is in the column — where the accessor pair, for all its faults, was right there. That is a real loss of locality and is the standard trade for removing duplication.

The subtler cost is that the cast can depend on sibling columns, and nothing declares that dependency. A cast reading $attributes['currency'] breaks silently on a model that lacks the column, on a query that selects only some columns, and on a model instantiated without them — the last of which happens in tests and produces an error message about an undefined index that names neither the cast nor the model. Guarding with a clear exception is worth the three lines.