whitecube/laravel-prices
Store and work with money in Laravel without floating-point errors. whitecube/laravel-prices wraps integer minor-unit amounts in a Price object with currency support, arithmetic, formatting helpers, and Eloquent casts, so prices stay precise and readable throughout your application.
whitecube/laravel-prices is a thin, opinionated wrapper around moneyphp/money that makes monetary values first-class citizens in Eloquent models. Instead of juggling raw integers and NumberFormatter calls all over your views, you get a Price object you can cast attributes to, do arithmetic on, and format for display.
Requirements (as of v2.0.0): PHP 8.3+ and Laravel 11+. This release dropped PHP 8.2 and Laravel 10, so verify your CI matrix and composer.json constraints before bumping.
First steps:
composer require whitecube/laravel-prices
php artisan vendor:publish --tag=prices-config
src/Price.php and src/Casts/ first. The package is small; the cast classes tell you exactly what storage shape each one expects, which is the single most important thing to get right on day one.First use case: add a price column and cast it.
// migration — store minor units (cents) as an integer
$table->integer('price')->default(0);
// model
use Whitecube\LaravelPrices\Casts\PriceCast;
protected $casts = [
'price' => PriceCast::class.':EUR',
];
$product->price = Price::EUR(1999); // €19.99
$product->save();
$product->price->format(); // "€19.99" (locale-aware)
The mental model to adopt immediately: the database stores integers, the application passes Price objects around, and only the view layer formats. Every other decision follows from that.
Casting is the primary integration point. Pick the cast that matches your schema and stick to it consistently:
$model->currency is an enum-ish value rather than a loose string.Keep arithmetic inside the package. Never $price->amount() / 100 in a controller. Compose instead:
$subtotal = $lineItems->reduce(
fn (?Price $carry, LineItem $item) => $carry?->add($item->total) ?? $item->total
);
$taxed = $subtotal->add($subtotal->multiply(0.21));
Price values are immutable, so add()/subtract()/multiply() return new instances — this makes them safe to pass into Blade components and API resources without defensive cloning.
Parse at the boundary, format at the edge. User input arrives as a string; convert it once in a FormRequest and let Price objects flow through the rest of the stack:
protected function prepareForValidation(): void
{
$this->merge(['price' => Price::parse($this->input('price'))]);
}
Format per-render, not per-store. Call ->format() inside Blade / API resources only. If you need multiple locales, pass the locale at format time rather than mutating the underlying value.
Discounts and VAT are a workflow, not a helper. For catalogues, store the base price and the discount rule separately, then expose an accessor that composes them:
protected function effectivePrice(): Attribute
{
return Attribute::get(fn () => $this->price->subtract($this->discount));
}
Test against the object, not the integer. Because the cast round-trips through the database, an assertion like $this->assertEquals(Price::EUR(1999), $product->fresh()->price) catches scale/rounding mistakes that assertDatabaseHas(['price' => 1999]) will happily miss.
API resources. Either let the cast's JSON representation through (check what the cast yields — it is usually a normalised array or string), or be explicit:
return [
'amount' => $this->price->format(),
'currency' => $this->price->currency()->getCode(),
];
Being explicit is strongly recommended for public APIs; the serialised shape is the package's concern, not your contract.
Minor units, always. Price::EUR(1999) is €19.99, not €1999. Half the confusion people report with money packages is scale: integers in the DB are in the currency's smallest unit, and JPY/KWD do not have two decimal places. The package inherits this from moneyphp/money, including its exceptions when scales mismatch.
Never use floats as an intermediate. Price::fromFloat(19.99) style conversions invite binary rounding errors. Parse strings ("19.99") or construct from integer minor units. If you must accept a float from a legacy API, round explicitly before constructing.
Currency mismatch throws. Adding EUR to USD raises an exception rather than silently converting. This is good — it catches bugs early — but it means a multi-currency order total needs a conversion strategy (your own, usually) before you call add(). Decide and document where that conversion lives.
Cast string syntax is easy to fumble. PriceCast::class.':EUR' requires the colon and no spaces. A malformed cast parameter typically surfaces as an unexpected default currency rather than a hard error, so add a test that asserts the currency on a freshly retrieved model.
Nullability. A nullable price column will hand you null from the cast, so every consumer needs ?->. Either make the column non-nullable with a 0 default or write an accessor that returns a zero Price — mixing the two behaviours across models is a common source of Call to a member function format() on null.
Upgrading to v2.0.0 is a dependency bump, not an API migration. The changelog lists only platform support changes (adds Laravel 13 / PHP 8.5, removes Laravel 10 / PHP 8.2), with no mentioned API changes. In practice:
composer why-not whitecube/laravel-prices ^2.0
composer update whitecube/laravel-prices --with-dependencies
php artisan test
If why-not blames a package pinning laravel/framework ^10, that dependency — not this one — is your blocker.
CI matrix. Bump php-version to 8.3/8.4/8.5 and laravel to 11.*/12.*/13.*. Dropping PHP 8.2 is the change most likely to break an otherwise-green pipeline, since it fails at install time rather than test time.
Formatting is locale-sensitive. ->format() respects the app locale and can change output between environments if your locale is set from the request. Snapshot tests asserting exact strings should pin the locale explicitly.
Extension points are limited by design. The package is a convenience layer; when you need exotic behaviour (custom rounding strategies, currency conversion, tax rules), drop down to the underlying Money object rather than fighting the wrapper, and keep that escape hatch in one place — a dedicated service class — so a future major version can't ripple through your codebase.
Watch the upstream repo, not just the changelog. Because the package tracks the Laravel release cycle, a new major usually lands within weeks of a Laravel major. Plan the bump alongside your framework upgrade rather than as an afterthought.
How can I help you explore Laravel packages today?