Handling Money Safely with a Custom Eloquent Cast
Storing money as a floating point number is a classic bug waiting to happen. Floats cannot represent most decimal fractions exactly, so sums drift by a cent and totals fail to reconcile. The durable fix is to store money as an integer number of cents. In this tutorial we build a custom Eloquent cast for PHP Architect that keeps the integer representation in the database while giving your code a tidy value object to work with.
Step 1: A Simple Money Value Object
First, a small immutable class to represent an amount. It holds cents internally and knows how to present itself:
namespace App\ValueObjects;
class Money
{
public function __construct(public readonly int $cents)
{
}
public static function fromDollars(float $dollars): self
{
return new self((int) round($dollars * 100));
}
public function dollars(): float
{
return $this->cents / 100;
}
public function format(): string
{
return '$' . number_format($this->dollars(), 2);
}
}
The rounding happens exactly once, at the boundary where dollars enter the system. After that, all arithmetic is integer arithmetic on cents.
Step 2: The Custom Cast
Eloquent casts implement CastsAttributes, which has two methods. get() transforms the raw database value into what your code sees, and set() transforms your code's value back into what gets stored:
namespace App\Casts;
use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class MoneyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?Money
{
return is_null($value) ? null : new Money((int) $value);
}
public function set($model, string $key, $value, array $attributes): ?int
{
if (is_null($value)) {
return null;
}
return $value instanceof Money ? $value->cents : (int) $value;
}
}
The database column is a plain integer. The cast is the only place that knows how to translate between that integer and the Money object.
Step 3: Wiring It to the Model
Register the cast on the model with the casts() method, pointing the price attribute at your cast class:
namespace App\Models;
use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected function casts(): array
{
return [
'price' => MoneyCast::class,
];
}
}
Step 4: Using It
Now the model attribute behaves like a Money object in both directions:
$product = Product::create([
'name' => 'Annual Subscription',
'price' => Money::fromDollars(99.00),
]);
echo $product->price->format(); // "$99.00"
echo $product->price->cents; // 9900
The stored column is the integer 9900. Your application code never touches a float after the initial conversion, which is exactly what keeps the rounding bugs away.
Step 5: A Note on Real Projects
For anything beyond a single currency, reach for a dedicated money library rather than growing this value object indefinitely. Multi-currency support, allocation, and formatting rules get complicated fast, and those problems are well solved. The pattern shown here, an integer column plus a custom cast, is still the right foundation. You are simply swapping the hand-rolled Money class for a battle-tested one behind the same cast.
Further Reading
- Laravel documentation, "Eloquent: Mutators & Casting", Custom Casts (laravel.com/docs/13.x/eloquent-mutators#custom-casts)
- Laravel documentation, "Eloquent: Mutators & Casting", Value Object Casting (laravel.com/docs/13.x/eloquent-mutators#value-object-casting)