From a88bd1a801d9ef94e3f82e256ecca592f30f4a2c Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 20:43:56 +0200 Subject: [PATCH 1/3] Correct decimal documentation and verify beginner setup --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ .github/workflows/deploy-docs.yml | 2 +- README.md | 11 ++++------- docs/.vitepress/theme/custom.css | 3 ++- docs/README.md | 2 +- docs/guide/getting-started.md | 13 +++++++++++-- docs/guide/installation.md | 7 ++----- docs/reference/alternatives.md | 11 +++++++++-- docs/reference/integrations.md | 4 +--- src/Decimal.php | 15 +++++++-------- 10 files changed, 66 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e40c7f8..638f53e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: - master workflow_dispatch: +permissions: + contents: read + jobs: testsuite: runs-on: ubuntu-latest @@ -17,6 +20,8 @@ jobs: steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -50,6 +55,8 @@ jobs: steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -66,3 +73,24 @@ jobs: - name: Run phpcs run: composer cs-check + + docs: + name: Documentation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: docs/package-lock.json + - run: npm ci + working-directory: docs + - run: npm audit --audit-level=low + working-directory: docs + - run: npm run docs:build + working-directory: docs diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index af440c4..4c607b0 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - fetch-depth: 0 + persist-credentials: false - name: Setup Node uses: actions/setup-node@v6 diff --git a/README.md b/README.md index d0b1ee3..35da99e 100644 --- a/README.md +++ b/README.md @@ -34,20 +34,17 @@ var_dump($total->equals('0.3')); // bool(true) ## Features -- Strict about precision and scale. Significant digits are only dropped when you `trim()` or round. +- Explicit scale handling for arithmetic, truncation and rounding. - Readable API: `add()`, `greaterThan()`, `isZero()`. - Arithmetic, rounding, comparison and conversion. - Immutable. - Handles numbers beyond the integer and float range. -- No custom PHP extension, only `bcmath`. +- Calculates with PHP's `bcmath` extension. ## Installation -``` -composer require php-collective/decimal-object -``` - -Requires PHP 8.2+ and the `bcmath` extension. PHP 8.1 is covered by the 1.3 releases. For anything older, use the [legacy library](https://github.com/spryker/decimal-object). +Follow [Installation](https://php-collective.github.io/decimal-object/guide/installation) +to install the package and enable bcmath for the PHP process that runs it. ## Usage diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 0320544..9f5efbb 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -19,7 +19,8 @@ .vp-doc table code, .vp-doc p code, .vp-doc li code { - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .vp-doc .custom-block.tip { diff --git a/docs/README.md b/docs/README.md index 25b6e28..c8b402c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,6 @@ To preview the site locally: ```sh cd docs -npm install +npm ci npm run docs:dev ``` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 1fa57be..37f55be 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,5 +1,7 @@ # Getting started +Complete [Installation](./installation) first. + Floats cannot represent most decimal fractions exactly: ```php @@ -9,15 +11,22 @@ var_dump(0.1 + 0.2 - 0.3); // float(5.551115123125783E-17) `Decimal` stores the number as a string of digits and calculates with bcmath, so the result is the one you would get on paper: -```php +Save this as `example.php` in the directory containing `composer.json`: + +```php [example.php] +add('0.2'); -echo $sum; // 0.3 +echo $sum, PHP_EOL; // 0.3 var_dump($sum->equals('0.3')); // bool(true) ``` +Run `php example.php`. Later snippets use the same Composer autoloader and `Decimal` import. + ## Immutability A `Decimal` never changes. Each operation returns a new object, so assign the result: diff --git a/docs/guide/installation.md b/docs/guide/installation.md index b852048..c63ef3e 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,12 +4,9 @@ composer require php-collective/decimal-object ``` -## Requirements +## Enable bcmath -- PHP 8.2 or newer -- The `bcmath` extension - -Composer checks both. Enable `ext-bcmath` for the CLI and for the web server, as they often load different `php.ini` files. +Composer checks PHP and extension compatibility. Enable `ext-bcmath` for the CLI and for the web server, as they often load different `php.ini` files. ::: tip Older PHP versions PHP 8.1 is served by the 1.3 releases, which Composer selects on its own. For PHP 7 use the original [spryker/decimal-object](https://github.com/spryker/decimal-object). diff --git a/docs/reference/alternatives.md b/docs/reference/alternatives.md index 6ad0f22..d7b4164 100644 --- a/docs/reference/alternatives.md +++ b/docs/reference/alternatives.md @@ -4,7 +4,7 @@ This library is one way to handle decimals in PHP. Depending on the project anot | Library | Notes | | --- | --- | -| [php-decimal](https://php-decimal.github.io/) | A PHP extension. Faster, and it supports the arithmetic operators, but it has to be installed on every server. Works with precision (significant digits) where this library works with scale. | +| [php-decimal](https://php-decimal.github.io/) | A PHP extension with arithmetic operators, installed separately on each server. Works with precision (significant digits) where this library works with scale. | | [brick/math](https://github.com/brick/math) | `BigDecimal`, `BigInteger` and `BigRational` with several calculation engines and more rounding modes. | | [brick/money](https://github.com/brick/money), [moneyphp/money](https://github.com/moneyphp/money) | Money with currencies, allocation and formatting. Choose one of these when you need currency handling and not only a number. | @@ -16,9 +16,16 @@ This library is one way to handle decimals in PHP. Depending on the project anot | `abs()` | `absolute()` | | `signum()` | `sign()` | | `sub()`, `mul()`, `div()` | `subtract()`, `multiply()`, `divide()` | +| `shift()`, `isEven()`, `isOdd()` | Same method names | +| `toFixed($places)` | `(string)$decimal->withScale($places)` | +| `Decimal::sum()` | `Decimal::sum()` | +| `Decimal::avg()` | `Decimal::average($values, $scale)` | | Precision as significant digits | Scale as decimal places | -Not available here: `rem()`, `shift()`, `ln()`, `exp()`, `log10()`, `parity()`, `isEven()`, `isOdd()`, `toFixed()`, `Decimal::sum()` and `Decimal::avg()`. +There are no methods named `rem()`, `ln()`, `exp()`, `log10()` or `parity()` here. +For a quotient and remainder, use [`divideWithRemainder()`](../guide/arithmetic#quotient-and-remainder). +Precision and scale differ between the libraries, so review rounding and division +when migrating calculations. ## History diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index af31833..3d78a85 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -4,9 +4,7 @@ [dereuromark/cakephp-decimal](https://dereuromark.github.io/cakephp-decimal/) maps `DECIMAL` database columns to `Decimal` objects in the CakePHP ORM. Entities receive value objects on read, and form input is converted during marshalling. -```sh -composer require dereuromark/cakephp-decimal -``` +Follow the plugin's [Installation](https://dereuromark.github.io/cakephp-decimal/guide/installation) for setup. ```php $order = $orders->get($id); diff --git a/src/Decimal.php b/src/Decimal.php index ccbcbd2..4c351d2 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -176,9 +176,8 @@ protected function normalizeValue(string $value): string /** * Returns a Decimal instance from the given value. * - * If the value is already a Decimal instance, then (since immutable) return it unmodified. - * Otherwise, create a new Decimal instance from the given value and return - * it. + * If the value is already an instance of this class and no scale is supplied, + * return a clone. Otherwise, construct a new instance from the value. * * @param object|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. @@ -351,7 +350,7 @@ protected static function extreme(iterable $values, int $direction): static * @param self|string|float|int $value * * @return bool TRUE if this decimal is considered equal to the given value. - * Equal decimal values tie-break on precision. + * Trailing zeros do not affect equality. */ public function equals(self|string|float|int $value): bool { @@ -449,7 +448,7 @@ public function add(self|string|float|int $value, ?int $scale = null): static /** * Return an appropriate scale for an arithmetic operation on two Decimals. * - * If $scale is specified and is a valid positive integer, return it. + * If $scale is specified, return it, including zero. * Otherwise, return the higher of the scales of the operands. * * @param self $a @@ -543,7 +542,7 @@ public function isInteger(): bool } /** - * Returns if truly zero. + * Returns whether the value is zero. * * @return bool */ @@ -553,7 +552,7 @@ public function isZero(): bool } /** - * Returns if truly negative (not zero). + * Returns whether the value is negative. * * @return bool */ @@ -563,7 +562,7 @@ public function isNegative(): bool } /** - * Returns if truly positive (not zero). + * Returns whether the value is positive. * * @return bool */ From 659fd71acfa218ac11e41df89ff1ec58a51e2a14 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 20:48:27 +0200 Subject: [PATCH 2/3] Preserve stored fractional digits in comparisons --- src/Decimal.php | 2 +- tests/DecimalTest.php | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Decimal.php b/src/Decimal.php index 4c351d2..68a066d 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -424,7 +424,7 @@ public function lessThanOrEquals(self|string|float|int $value): bool public function compareTo(self|string|float|int $value): int { $decimal = static::create($value); - $scale = max($this->scale(), $decimal->scale()); + $scale = max($this->scale(), $decimal->scale(), strlen($this->fractionalPart), strlen($decimal->fractionalPart)); return bccomp((string)$this, (string)$decimal, $scale); } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 2ccdc6b..9e3d517 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -601,6 +601,26 @@ public function testEquals(): void $this->assertTrue($result); } + /** + * @return void + */ + public function testComparisonUsesStoredFractionalDigits(): void + { + $positive = Decimal::create('1.9', 0); + $negative = Decimal::create('-1.9', 0); + $smaller = Decimal::create('1.8', 0); + + $this->assertSame('1.9', (string)$positive); + $this->assertSame(0, $positive->scale()); + $this->assertSame(1, $positive->compareTo(1)); + $this->assertSame(-1, Decimal::create(1)->compareTo($positive)); + $this->assertSame(-1, $negative->compareTo(-1)); + $this->assertSame(1, Decimal::create(-1)->compareTo($negative)); + $this->assertSame(1, $positive->compareTo($smaller)); + $this->assertFalse($positive->equals($smaller)); + $this->assertTrue($positive->equals('1.90')); + } + /** * @param mixed $value * @param int $scale From 2fbb0eae4d0841fe5a9b6bc358018167308692ad Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 11 Oct 2026 20:51:38 +0200 Subject: [PATCH 3/3] Clarify decimal construction and scale edge cases --- docs/guide/creating.md | 6 +++--- docs/guide/getting-started.md | 6 +++--- docs/guide/installation.md | 4 ++-- docs/guide/rounding.md | 2 +- docs/guide/scale.md | 4 ++-- docs/index.md | 2 +- docs/reference/alternatives.md | 4 +++- docs/reference/api.md | 4 ++-- docs/reference/integrations.md | 2 +- docs/reference/upgrading.md | 4 ++++ src/Decimal.php | 10 +++++----- 11 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/guide/creating.md b/docs/guide/creating.md index 6686525..d021c21 100644 --- a/docs/guide/creating.md +++ b/docs/guide/creating.md @@ -7,7 +7,7 @@ $a = Decimal::create('1.234'); $b = new Decimal('1.234'); ``` -Both forms take the same arguments. `create()` reads better in a chain and also accepts an existing `Decimal`. +Both forms accept numeric values and `Stringable` objects, including an existing `Decimal`. Without an explicit scale, `create()` clones an instance of the called class and preserves its declared scale; the constructor reads the string value and detects its scale again. ## Accepted input @@ -79,7 +79,7 @@ Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `I ## Optional input -`tryCreate()` returns `null` where `create()` would throw for a value that is not numeric, and passes `null` through: +`tryCreate()` returns `null` for `null` or any input rejected with `InvalidArgumentException`, including non-numeric or non-finite values, excessive exponents and negative scales: ```php Decimal::tryCreate('1.50'); // Decimal 1.50 @@ -114,7 +114,7 @@ echo $value->add('0.2'); // 0.3000 ``` ::: warning A scale of 0 -`0` is read as "no scale given" and does not cut off decimals: `Decimal::create('1.5', 0)` stays `1.5`. Use `truncate()` or `round()` to get a whole number. The next major version will treat `0` like any other scale. +`0` is read as "no scale given" and does not cut off decimals: `Decimal::create('1.5', 0)` stays `1.5`. Its declared scale is still 0, so later arithmetic can truncate fractional digits: adding `1` produces `2`. Comparisons use the stored value. Use `truncate()` or `round()` to get a whole number. The next major version will treat `0` like any other scale. ::: ### Strict mode diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 37f55be..1cccba0 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -9,11 +9,11 @@ var_dump(0.1 + 0.2 === 0.3); // bool(false) var_dump(0.1 + 0.2 - 0.3); // float(5.551115123125783E-17) ``` -`Decimal` stores the number as a string of digits and calculates with bcmath, so the result is the one you would get on paper: +`Decimal` stores the number as a string of digits and calculates with bcmath, so decimal fractions can be added exactly. Save this as `example.php` in the directory containing `composer.json`: -```php [example.php] +```php greaterThan('20')) { ## Use strings for input -Pass decimal strings whenever you can. A float has already lost digits before the object sees it: +Pass decimal strings whenever you can. Float conversion follows PHP's `precision` setting. With its default of 14 digits, string conversion drops digits in this example: ```php echo Decimal::create('1234567890.1234567'); // 1234567890.1234567 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index c63ef3e..a2d42d8 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,12 +4,12 @@ composer require php-collective/decimal-object ``` -## Enable bcmath +## Compatibility Composer checks PHP and extension compatibility. Enable `ext-bcmath` for the CLI and for the web server, as they often load different `php.ini` files. ::: tip Older PHP versions -PHP 8.1 is served by the 1.3 releases, which Composer selects on its own. For PHP 7 use the original [spryker/decimal-object](https://github.com/spryker/decimal-object). +PHP 8.1 is served by the 1.3 releases, which Composer selects on its own. For PHP versions older than 8.1, use the original [spryker/decimal-object](https://github.com/spryker/decimal-object). ::: Continue with [Getting started](./getting-started). diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md index 83f6617..33affe4 100644 --- a/docs/guide/rounding.md +++ b/docs/guide/rounding.md @@ -16,7 +16,7 @@ echo Decimal::create('-12.5')->round(); // -13 echo Decimal::create('11')->round(2); // 11.00 ``` -The first argument is the number of decimal places, `0` by default. The result always has exactly that scale, padded with zeros if needed. +The first argument is the number of decimal places, `0` by default. For non-negative scales, the half-rounding modes pad the result with zeros if needed. Negative scales round to positions before the decimal point. `ROUND_FLOOR` and `ROUND_CEIL` have the legacy behavior described below. The default mode rounds half away from zero, like PHP's `round()`. It works on the decimal digits and never passes through a float. diff --git a/docs/guide/scale.md b/docs/guide/scale.md index cb03b0b..b3a0187 100644 --- a/docs/guide/scale.md +++ b/docs/guide/scale.md @@ -19,9 +19,9 @@ The library is strict about it: a value keeps the scale it was created with, and | `divide()` | none, the argument is required | `10 / 3` at scale 4 is `3.3333` | | `divideWithRemainder()` | 0 for the quotient, larger scale of both for the remainder | `10 / 0.3` is `33` and `0.1` | | `pow()`, `sqrt()`, `mod()` | scale of the value | `1.5 ** 2 = 2.2` | -| `round()`, `truncate()` | the scale you pass | `12.375` rounded to 2 is `12.38` | +| `round()`, `truncate()` | requested scale; truncation does not pad | `12.375` rounded to 2 is `12.38` | -Every arithmetic method takes a scale argument to override the default. When the exact result has more digits than the scale allows, the rest is cut off without rounding. `divide()` rounds when you pass a mode as third argument. +`add()`, `subtract()`, `multiply()`, `divide()`, `pow()`, `sqrt()` and `mod()` accept a result scale. When the exact result has more digits than the scale allows, the rest is cut off without rounding. `divide()` rounds when you pass a mode as third argument. ## Repeated multiplication diff --git a/docs/index.md b/docs/index.md index d30346a..ffd9896 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ layout: home hero: name: Decimal Object text: Exact decimal arithmetic for PHP - tagline: An immutable value object on top of bcmath. No custom extension, no float rounding error. + tagline: An immutable value object on top of bcmath. Uses bcmath, no float rounding error. image: src: /logo.svg alt: Decimal Object logo diff --git a/docs/reference/alternatives.md b/docs/reference/alternatives.md index d7b4164..737a009 100644 --- a/docs/reference/alternatives.md +++ b/docs/reference/alternatives.md @@ -17,6 +17,8 @@ This library is one way to handle decimals in PHP. Depending on the project anot | `signum()` | `sign()` | | `sub()`, `mul()`, `div()` | `subtract()`, `multiply()`, `divide()` | | `shift()`, `isEven()`, `isOdd()` | Same method names | +| `rem($value)` | `mod($value, $scale)`; set the result scale explicitly | +| `mod($value)` | Integer modulo in php-decimal; see the different [modulo behavior](../guide/arithmetic#modulo) here | | `toFixed($places)` | `(string)$decimal->withScale($places)` | | `Decimal::sum()` | `Decimal::sum()` | | `Decimal::avg()` | `Decimal::average($values, $scale)` | @@ -29,4 +31,4 @@ when migrating calculations. ## History -The library was written in 2019 for the [Spryker](https://spryker.com/) commerce framework and has been used there in order and price calculations since. This repository is the independently maintained continuation for PHP 8. The original for older PHP versions is [spryker/decimal-object](https://github.com/spryker/decimal-object). +The library was written in 2019 for the [Spryker](https://spryker.com/) commerce framework and has been used there in order and price calculations since. This repository is the independently maintained continuation for the PHP ecosystem. The original for older PHP versions is [spryker/decimal-object](https://github.com/spryker/decimal-object). diff --git a/docs/reference/api.md b/docs/reference/api.md index 9de3233..9be0738 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -9,8 +9,8 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and | Method | Description | | --- | --- | | `new Decimal($value, ?int $scale = null, bool $strict = false)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. | -| `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Same as the constructor, for chaining. | -| `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. | +| `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Creates a decimal; without a scale, clones an instance of the called class while preserving its declared scale. | +| `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` or input rejected with `InvalidArgumentException`. | | `Decimal::createFromFloat(float $value): static` | Keeps all digits of the float, independent of the `precision` ini setting. | | `Decimal::zero(): static`, `Decimal::one(): static` | The values `0` and `1`. | diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 3d78a85..f33e343 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -12,7 +12,7 @@ $order->total = $order->total->add('0.10'); $orders->saveOrFail($order); ``` -The plugin documentation covers setup, forms, localized input and formatting. +The plugin documentation also covers forms, localized input and formatting. ## Your own framework diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md index 634ab8c..147b132 100644 --- a/docs/reference/upgrading.md +++ b/docs/reference/upgrading.md @@ -49,6 +49,10 @@ Zero has no sign. `'-0.0'` becomes `0.0`, `isNegative()` is false for it, and `n `mod()` and `pow()` accept operands in scientific notation. +## 1.4.1 + +`mod()` and `pow()` now accept float operands. Subclasses overriding these methods must include `float` in their operand type to keep their signatures compatible. + ## Coming in 2.0 These corrections change results or output for ordinary input, so they wait for the next major version. The current behavior is documented on the linked pages. diff --git a/src/Decimal.php b/src/Decimal.php index 68a066d..17d6b77 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -345,7 +345,7 @@ protected static function extreme(iterable $values, int $direction): static /** * Equality * - * This method is equivalent to the `==` operator. + * Compares numeric values, ignoring differences in scale. * * @param self|string|float|int $value * @@ -424,7 +424,7 @@ public function lessThanOrEquals(self|string|float|int $value): bool public function compareTo(self|string|float|int $value): int { $decimal = static::create($value); - $scale = max($this->scale(), $decimal->scale(), strlen($this->fractionalPart), strlen($decimal->fractionalPart)); + $scale = max($this->scale(), $decimal->scale(), $this->fractionalLength(), $decimal->fractionalLength()); return bccomp((string)$this, (string)$decimal, $scale); } @@ -772,7 +772,7 @@ protected function fractionalLength(): int } /** - * This method is equivalent to the ** operator. + * Raises this decimal to a whole-number exponent. * * @param self|string|float|int $exponent * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. @@ -805,7 +805,7 @@ public function sqrt(?int $scale = null): static } /** - * This method is equivalent to the % operator. + * Returns the remainder at the configured scale. * * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. @@ -1106,7 +1106,7 @@ public function toFloat(): float /** * Returns the decimal as int. Does not round. * - * This method is equivalent to a cast to int. + * Converts the numeric value, discarding fractional digits. * * @throws \TypeError *