brick/math
brick/math is a PHP library for arbitrary precision arithmetic. It provides BigInteger, BigDecimal, BigRational, and BigNumber classes, automatically using GMP or BCMath when available for fast, exact calculations.
Install and verify the platform:
composer require brick/math:^0.19 # 0.x minor bumps can break; pin the cycle
php -m | grep -E '^(gmp|bcmath)$' # optional, but a big speed win
The library needs PHP 8.2+. Without GMP or BCMath it still works via a pure-PHP calculator, just slower — so on production containers either install an extension or benchmark before you put it in a hot path.
Where to look first:
src/BigNumber.php — the shared API (plus, minus, multipliedBy, compareTo, sign, toScale, conversions). Everything below BigInteger / BigDecimal / BigRational inherits from it.src/RoundingMode.php — the enum you will pass around constantly; read it before writing any money code.of() vs parse() warning. Those three are the whole learning curve.CHANGELOG/ — each 0.x cycle is a breaking release; check before bumping.First use case — an invoice line with tax, rounded once at the end:
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
$unitPrice = BigDecimal::of('19.99');
$subtotal = $unitPrice->multipliedBy(3); // 59.97
$tax = $subtotal->multipliedBy('0.20'); // 11.994 (exact, no float drift)
return [
'subtotal' => (string) $subtotal->toScale(2, RoundingMode::HalfUp), // 59.97
'tax' => (string) $tax->toScale(2, RoundingMode::HalfUp), // 11.99
'total' => (string) $subtotal->plus($tax)->toScale(2, RoundingMode::HalfUp),
];
Notice the shape of the workflow: parse strings in, do exact arithmetic, round only at the boundary where you persist or render, and emit strings.
1. Eloquent cast that always hands you a BigDecimal.
namespace App\Casts;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class BigDecimalCast implements CastsAttributes
{
public function __construct(private int $scale = 2) {}
public function get($model, $key, $value, $attributes): ?BigDecimal
{
return $value === null ? null : BigDecimal::of($value)->toScale($this->scale);
}
public function set($model, $key, $value, $attributes): ?string
{
return $value === null ? null
: (string) BigDecimal::of($value)->toScale($this->scale, RoundingMode::HalfUp);
}
}
protected $casts = [
'total' => BigDecimalCast::class.':2',
'unit_price' => BigDecimalCast::class.':4',
];
Pair this with DECIMAL(19,4) (or integer minor units in a BIGINT) columns. Never FLOAT/DOUBLE, and avoid Laravel's float cast. The stock decimal:2 cast is acceptable too — it returns a string, which is a safe input to BigDecimal::of() since DB values are trusted.
2. A single money service owns the rounding policy. Decide once whether you round per line then sum, or sum then round — brick/math will not decide for you. Centralise scale and RoundingMode there so controllers, jobs, and observers can't drift apart:
final class Money
{
public const SCALE = 2;
public static function round(BigDecimal $v): BigDecimal
{
return $v->toScale(self::SCALE, RoundingMode::HalfUp);
}
}
3. Validate untrusted input with parse(), not of().
Validator::extend('big_decimal', function ($attribute, $value) {
if (! is_string($value)) {
return false;
}
try {
BigDecimal::parse($value, maxDigits: 20)->toScale(2); // default mode: Unnecessary
return true;
} catch (\Brick\Math\Exception\MathException) {
return false;
}
});
toScale() with the default RoundingMode::Unnecessary doubles as a "at most 2 decimals" check. For a FormRequest, catch NumberFormatException and rethrow as a validation message so the user sees a field error, not a 500.
4. Keep BigNumber instances inside the domain, strings at the edges. In controllers, resources, JSON, cache payloads, and queued job properties, cast to (string). Sending "19.99" to JavaScript is also the only way to avoid the browser's own float truncation.
5. Comparison and sorting. Use the API, not operators:
$invoices->sort(fn ($a, $b) => $a->total->compareTo($b->total));
if ($balance->isLessThan(0)) { /* ... */ }
compareTo() works across types (BigDecimal vs BigInteger), so mixed sources are fine as long as conversion is lossless.
6. BigRational for exact rates. Tax rates, unit conversions, and proration factors are naturally fractions:
$rate = BigRational::of('7/100');
$share = BigRational::of('1/3');
$amount = BigDecimal::of('100.00')->toBigRational()->multipliedBy($rate)->multipliedBy($share);
Convert back to BigDecimal with an explicit scale when you persist — the rational result is exact, the decimal is not.
7. Testing. Assert against strings, never floats: $this->assertSame('71.96', (string) $total);. This catches scale regressions ("71.960") that a float comparison would hide.
8. Bitwise helpers. For flag sets or IDs beyond 64 bits, BigInteger has and(), or(), xor(), not(), shiftedLeft(), shiftedRight(). Handy, but if a native int fits, use it — it is much faster.
of() is a DoS vector. A string like 1e1000000000 can allocate gigabytes. Any value from a request, API, CSV, or queue payload must go through BigDecimal::parse($v, allowedSyntax: NumberSyntax::SCIENTIFIC, maxDigits: 100) and be caught as NumberFormatException.json_decode() turns 19.99 into a float, so a JSON API posting amounts as numbers is already lossy before you see it. Prefer string amounts or minor-unit integers; if you must accept a float, use BigDecimal::fromFloatShortest($f) (what the user meant) or fromFloatExact($f) (what the float actually holds — usually not what you want).BigDecimal::dividedBy() requires a scale. dividedBy('8', 2) throws RoundingNecessaryException; supply a RoundingMode. Use dividedByExact() when the result is known to terminate (1/256 yes, 1/11 no).BigInteger::dividedBy() throws on a remainder. That is intentional and a good bug-catcher. Use quotient(), remainder(), or quotientAndRemainder() when you actually want truncation.multipliedBy() adds scales, plus() takes the max. A few chained operations can produce 0.300000000000 and blow past DECIMAL(19,4). Always toScale() before set(), and stripTrailingZeros() when normalising values for comparison or storage.BigDecimal::of('1.0')->isEqualTo('1.00') is true, but they are distinct objects. Never use == or === on these; use compareTo() === 0.toInteger() overflows loudly. Anything beyond PHP_INT_MAX throws IntegerOverflowException — good, but it means you cannot round-trip a 30-digit bigint through toInteger(). Keep it as a string.toFloat() is a one-way door. Only for legacy APIs and display. Never feed the result back into arithmetic.$total->plus($fee); does nothing, and $a->plus($b) returns a new instance — reassign or chain.MathException at boundaries and translate. RoundingNecessaryException → 422 validation error, DivisionByZeroException → 422, NumberFormatException → 400, everything else → 500. Use the simPod/phpstan-brick-math extension so PHPStan knows which methods throw which subclasses.Unnecessary throwing is a feature: it surfaces silent rounding. For accounting, HalfEven reduces cumulative bias; HalfUp matches most invoice expectations. Be explicit at every call site.BigDecimal is not a JS-safe number and not a JsonSerializable scalar you should rely on blindly — cast to string in JsonResource/toArray(). If you return the object directly from a cast on a model, json_encode will not do what you want.0.x minors are breaking by design (dividedBy() semantics, getScale() → scale(), of() → nd() for rationals). Lock to ^0.19, read the changelog before upgrading, and keep the requirement in composer.json explicit so CI catches a PHP 8.1 host.How can I help you explore Laravel packages today?