Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
- master
workflow_dispatch:

permissions:
contents: read

jobs:
testsuite:
runs-on: ubuntu-latest
Expand All @@ -17,6 +20,8 @@ jobs:

steps:
- uses: actions/checkout@v6
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand Down Expand Up @@ -50,6 +55,8 @@ jobs:

steps:
- uses: actions/checkout@v6
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@v2
Expand All @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/.vitepress/theme/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ To preview the site locally:

```sh
cd docs
npm install
npm ci
npm run docs:dev
```
6 changes: 3 additions & 3 deletions docs/guide/creating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
# Getting started

Complete [Installation](./installation) first.

Floats cannot represent most decimal fractions exactly:

```php
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
<?php
require __DIR__ . '/vendor/autoload.php';

use PhpCollective\DecimalObject\Decimal;

$sum = Decimal::create('0.1')->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:
Expand Down Expand Up @@ -46,7 +55,7 @@ if ($gross->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
Expand Down
9 changes: 3 additions & 6 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@
composer require php-collective/decimal-object
```

## Requirements
## Compatibility

- 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).
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).
2 changes: 1 addition & 1 deletion docs/guide/rounding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/scale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions docs/reference/alternatives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -16,10 +16,19 @@ 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 |
| `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)` |
| 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

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).
4 changes: 2 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand Down
6 changes: 2 additions & 4 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@

[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);
$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

Expand Down
4 changes: 4 additions & 0 deletions docs/reference/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 12 additions & 13 deletions src/Decimal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -346,12 +345,12 @@ 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
*
* @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
{
Expand Down Expand Up @@ -425,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(), $this->fractionalLength(), $decimal->fractionalLength());

return bccomp((string)$this, (string)$decimal, $scale);
}
Expand All @@ -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
Expand Down Expand Up @@ -543,7 +542,7 @@ public function isInteger(): bool
}

/**
* Returns if truly zero.
* Returns whether the value is zero.
*
* @return bool
*/
Expand All @@ -553,7 +552,7 @@ public function isZero(): bool
}

/**
* Returns if truly negative (not zero).
* Returns whether the value is negative.
*
* @return bool
*/
Expand All @@ -563,7 +562,7 @@ public function isNegative(): bool
}

/**
* Returns if truly positive (not zero).
* Returns whether the value is positive.
*
* @return bool
*/
Expand Down Expand Up @@ -773,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.
Expand Down Expand Up @@ -806,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.
Expand Down Expand Up @@ -1107,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
*
Expand Down
Loading
Loading