whitecube/php-prices
A PHP library for working with prices and currencies: immutable Price objects, VAT rates, discounts, arithmetic, and locale-aware formatting/parsing via intl. Framework-agnostic, with optional Laravel integration.
whitecube/php-prices is a small, framework-agnostic value-object layer on top of brick/money for the one thing money libraries tend to leave you to build yourself: VAT (and other modifiers) on a price, and the base/VAT/total breakdown that invoices, carts and quotes need.
composer require whitecube/php-prices
It requires PHP 8.x and pulls in brick/money / brick/math. There is no service provider and no config — in a Laravel app you use it as a plain object inside your own value objects, actions and casts. If you want Eloquent casts and Blade helpers on top, pair it with whitecube/laravel-prices, but start here first so you understand the underlying object.
Read the README in this order: the Price constructor and arithmetic, then the VAT section, then modifiers. That is roughly the order you will need them.
First use case — a cart line:
use Whitecube\Price\Price;
$unit = new Price(1999, 'EUR'); // €19.99
$unit->setVat(21); // 21% VAT
$line = $unit->multipliedBy(3); // 3 units
(string) $line->exclVat(); // "49.59" — net
(string) $line->getVat(); // "10.41" — VAT portion
(string) $line->inclVat(); // "60.00" — gross
The single most important idea: a Price carries its VAT rate with it, so downstream code never has to ask "is this number net or gross?".
Store and transport cents, never floats. Keep getAmount() (integer minor units) in the database and in API payloads; rebuild the Price when you hydrate. A decimal(10,2) column works too, but cents removes an entire class of rounding argument.
Treat the object as immutable. plus(), minus(), multipliedBy(), dividedBy() and the VAT methods hand you back a new instance — always reassign. This is a feature: a Price can be passed into a pricing rule, a discount calculator and a view composer without anyone mutating it behind your back.
Build a line-item DTO and price it in one place.
final class LineItem
{
public function __construct(
public readonly string $sku,
public readonly int $quantity,
public readonly Price $unitPrice,
) {}
public function total(): Price
{
return $this->unitPrice->multipliedBy($this->quantity);
}
}
Sum with a reduce, not with floats.
$total = $lines->reduce(
fn (?Price $carry, LineItem $line) => $carry
? $carry->plus($line->total())
: $line->total(),
null
);
Because each line already knows its VAT rate, $total->getVat() gives you the correct aggregate tax figure without a second pass — this is exactly the code you would otherwise write by hand and get subtly wrong.
Apply the VAT rate at the boundary, once. Keep catalogue and cost data net, set setVat() when you build the sellable price (usually from a product/tax-rate model), and let everything downstream be rate-aware. Avoid calling setVat() on an already-gross price just to "convert" it — decide which side of VAT your source number sits on first, then construct accordingly.
Discounts: order matters, so make it explicit. Compute the discount from the net price, subtract it, and only then read inclVat()/getVat() so the tax figure follows the discounted amount. If your business gives discounts on the gross, do it the other way round — but write it down in a comment, because this is the line a finance person will ask about.
Money interop. getMoney() returns the underlying Brick\Money\Money and getCurrency() the currency, so you can hand off to exchange-rate or accounting libraries without re-parsing. Conversely you can construct from a Money instance when integrating with a package that already speaks brick/money.
Formatting belongs at the edge. (string) $price gives you the plain decimal amount (what you want for data-* attributes and logs); use the formatting helpers from whitecube/laravel-prices (or your own NumberFormatter wrapper) for what humans see. Keep those two paths separate — do not store a formatted string.
In Laravel: wrap it in a cast (AsPrice) or, more simply, keep Price construction inside your actions and expose int cents through API resources. In Blade, use a @price directive or a Price method rather than number_format() scattered through templates.
new Price(1000) and new Price(10.00) are easy to confuse — the library treats an int as minor units and a float/string as a decimal amount. Pick one style in your codebase (I'd say integers everywhere) and never mix them in the same file.plus/multipliedBy at full precision and round only when persisting or rendering. Premature rounding on unit prices times quantity is the classic source of one-cent drift.setVat() is being applied to the price as net (the usual, and the assumption in the example above) or whether you are starting from a gross figure. If you start from gross, compute the net once and construct from that, rather than trying to reverse the percentage inline.dividedBy() for unit prices is a rounding trap. Splitting a €10 total across 3 units gives an infinite decimal; the result is only as precise as the underlying scale. Do the division for display, but keep the original total as the source of truth.json_encode a Price or cast it to string and rehydrate, you get the amount and currency back but you must re-apply setVat() yourself. Store the rate alongside the amount if it isn't derivable from the product.getMoney(). When a number looks wrong, dump (string) $price->getMoney() next to (string) $price->exclVat() and (string) $price->getVat() — it immediately shows whether the problem is the amount, the rate, or the gross/net direction.Price into a tax engine.brick/money major versions, so pin sensibly and keep a couple of tests covering VAT math, totals and rounding. Those tests are the real safety net for this package.How can I help you explore Laravel packages today?