penangites / number
Immutable, chainable Number and Percentage value objects with exact bcmath arithmetic.
Requires
- php: ^8.3
- ext-bcmath: *
Requires (Dev)
- laravel/pint: ^1.27
- pestphp/pest: ^4.7
- phpstan/phpstan: ^2.1
- spaze/phpstan-disallowed-calls: ^4.13
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
An immutable, chainable Number value object for PHP with exact bcmath
arithmetic — plus a Percentage companion. No runtime dependencies beyond
bcmath, no floating-point arithmetic, no drift.
use Penangites\Number\Number; use Penangites\Number\Percentage; Number::of('19.90') ->tax(Percentage::fromPercent('6')) ->toString(); // "21.094" — exact, no rounding until you ask Number::of('19.90') ->tax(Percentage::fromPercent('6')) ->round(2) ->toString(); // "21.09"
After construction, addition, subtraction, multiplication and percentage
operations are exact at any precision. Only divide() and round() discard
digits — and they take an explicit scale and rounding mode, so precision is
never lost silently.
For Laravel integration (Eloquent casts, validation), see
penangites/laravel-number.
Installation
Requires PHP 8.3+ and the bcmath extension.
composer require penangites/number
Usage
Creating numbers
Number::of('19.90'); // strings are exact — prefer them Number::of(100); // integers are exact Number::of(1.5); // floats accepted, stored as the double really holds them Number::of('1.0E-5'); // scientific notation is expanded exactly
Use strings for decimal values when every supplied digit must be preserved.
A float is stored as the shortest decimal that reads back as the same double, so nothing is invented and nothing is dropped — but a float has already lost whatever it lost before the call, and that shows:
Number::of(0.1 + 0.2)->toString(); // "0.30000000000000004" — never was 0.3 Number::of('0.1')->add('0.2'); // "0.3" — strings stay exact
Scientific notation is accepted wherever a decimal string is — Percentage
included — because it is what a float cast, a JSON decode or a database driver
hands back. It is expanded positionally, so '1.0E-5' and '0.00001' are the
same value. Exponents beyond ±100000 are refused: a value needing more digits
than that should be written out in full, where the input is as large as the
result.
Reading values
$n = Number::of('19.90'); $n->toString(); // "19.9" — canonical decimal string (string) $n; // same $n->toFloat(); // 19.9 $n->toInt(); // throws — has a fractional part; round() first json_encode($n); // '"19.9"' — serialises as the exact string
toInt() only converts losslessly: it throws on fractional values instead of
silently truncating.
Arithmetic
Every operation returns a new instance — a Number never changes. Operands
can be another Number, an int, a string, or a float.
Number::of('0.1')->add('0.2')->toString(); // "0.3" — no float drift Number::of('0.3')->subtract('0.1')->toString(); // "0.2" Number::of('1.5')->multiply('2.5')->toString(); // "3.75" — always exact Number::of('1.5')->negate()->toString(); // "-1.5" Number::of('-1.5')->abs()->toString(); // "1.5"
Division and rounding
Division is the one operation that can produce endless digits, so it takes an
explicit scale (default 12) and a RoundingMode (default half away from zero):
use Penangites\Number\RoundingMode; Number::of(1)->divide(3)->toString(); // "0.333333333333" Number::of(10)->divide(3, 2)->toString(); // "3.33" Number::of(10)->divide(3, 2, RoundingMode::Up)->toString(); // "3.34" Number::of('2.5')->round()->toString(); // "3" — half away from zero Number::of('1.005')->round(2)->toString(); // "1.01" — floats get this wrong
Modes: HalfAwayFromZero (default), Up, Down, Ceiling, Floor. Rounding
uses the exact remainder, so every mode is correct even when the discarded
digits lie far beyond the scale.
Values are returned in canonical form without trailing zeros. The scale limits decimal precision; it does not format or pad the result:
Number::of('1.204')->round(2)->toString(); // "1.2", not "1.20"
Percentages
$rate = Percentage::fromPercent('6'); // 6% $rate = Percentage::fromRatio('0.06'); // same thing (1 = 100%) $rate->toPercent(); // "6" $rate->toRatio(); // "0.06" $rate->toFloat(); // 0.06 — approximate; use toRatio() when exact $rate->toArray(); // ['ratio' => '0.06', 'percent' => '6'] json_encode($rate); // '"0.06"' — reads back with fromRatio()
A Percentage has no string cast: "0.06" and "6" are both plausible, so ask
for toPercent(), toRatio() or toArray() by name rather than have the wrong
one render silently.
Percentage supports exact add, subtract, negate, comparisons and sign
checks of its own:
$rate = Percentage::fromRatio('0.06'); // 6% $rate->add(Percentage::fromPercent('2'))->toPercent(); // "8" $rate->greaterThan(Percentage::fromRatio('0.05')); // true $rate->isPositive(); // true
Apply one to a Number:
$price = Number::of('19.90'); $tax = Percentage::fromPercent('6'); $price->percentOf($tax)->toString(); // "1.194" — the tax amount $price->tax($tax)->toString(); // "21.094" — price + tax $price->discount($tax)->toString(); // "18.706" — price - discount
increaseBy / decreaseBy are the general-purpose names for tax /
discount. All percentage operations are exact — chain ->round() when you
need to limit decimal precision.
A complete price calculation can stay exact until the final rounding step:
$total = Number::of('199.99') ->discount(Percentage::fromPercent('15')) ->tax(Percentage::fromPercent('8')) ->round(2); $total->toString(); // "183.59"
Comparison
$a = Number::of('1.5'); $a->equals('1.50'); // true — compared by value $a->greaterThan(1); // true $a->lessThanOrEqual($a); // true $a->compareTo(2); // -1 $a->isZero(); $a->isPositive(); $a->isNegative();
Recipes
Spelling a number out in words
Deliberately not part of this package — PHP's intl extension already does it
in every CLDR locale, so use it directly:
$number = Number::of(100); $english = new NumberFormatter('en', NumberFormatter::SPELLOUT); $chinese = new NumberFormatter('zh', NumberFormatter::SPELLOUT); $english->format($number->toFloat()); // "one hundred" $chinese->format($number->toFloat()); // "一百"
Testing
composer test
This runs code style checks (Pint), static analysis (PHPStan at level 10, the maximum) and the Pest test suite.
Changelog
See CHANGELOG for what has changed recently.
Contributing
See CONTRIBUTING for details.
Security
If you discover a security issue, please read our security policy — do not use the issue tracker.
License
MIT — see LICENSE.