Brick\Money

repository·main·Indexed 24 days ago

https://github.com/brick/money

A PHP library for handling monetary values with high precision. It supports multiple currencies, complex allocation and splitting logic, and currency conversion via extensible exchange rate providers. Key features include the MoneyBag for aggregating different currencies, a MoneyComparator for cross-currency comparisons, and support for ISO 4217 and custom currencies. It provides various exchange rate providers including PdoProvider, CachedProvider, and ChainProvider, and requires PHP 8.2+ for the current version.

Tokens
6.4K
Snippets
19
Records
22
Agent score
34%

What's inside brick/money

  1. Choose a comparison mode for MoneyComparator

    main

    When instantiating a MoneyComparator, you must select a comparison mode to define how currency conversion affects the comparison logic:

    1. PairwiseMode: Converts the first operand into the second operand's currency (without rounding) before comparing. This is highly precise for a single pair but can be non-transitive (e.g., A < B, B < C, and C < A could all be true) if exchange rates are asymmetric. Results of min()/max() may depend on the order of arguments.
    2. BaseCurrencyMode: Converts both operands to a common base currency before comparing. This ensures consistent, transitive ordering (if A ≤ B and B ≤ C, then A ≤ C), making min()/max() results independent of argument order. You must provide the base currency to the constructor.

    If your ExchangeRateProvider supports dimensions (like time), you can pass them as the third argument to the MoneyComparator constructor.

  2. Handle multiple currencies with MoneyBag

    main

    A MoneyBag is used to aggregate multiple Money objects of different currencies. You can create a bag from existing monies or by chaining plus() calls starting from MoneyBag::zero().

    MoneyBag can contain Money, RationalMoney, or other MoneyBag instances. To convert the total value of a bag into a single currency, use a CurrencyConverter.

    use Brick
    Money\\Money;
    use Brick
    Money\\MoneyBag;
    
    $eur = Money::of('12.34', 'EUR');
    $jpy = Money::of(123, 'JPY');
    
    $moneyBag = MoneyBag::of($eur, $jpy);
    
    // Or via chaining:
    $moneyBag = MoneyBag::zero()->plus($eur)->plus($jpy);
  3. Use Money Contexts to control scale and rounding

    main

    By default, Money uses the DefaultContext (official ISO 4217 scale and 1-unit increments). You can override this behavior by providing a Context instance during creation. All subsequent operations on that Money instance will preserve the same context.

    CashContext

    Used for currencies where cash increments differ from the standard scale (e.g., Swiss Francs where coins only exist in 5-cent increments).

    CustomContext

    Used to define a specific decimal scale for calculations.

    AutoContext

    Used when you want the scale to adjust automatically to fit the operation result. Warning: Do not use AutoContext for intermediate steps in divisions that might result in infinite repeating decimals; use RationalMoney instead.

    use Brick\Money\Money;
    use Brick\Money\Context\CashContext;
    use Brick\Money\Context\CustomContext;
    use Brick\Money\Context\AutoContext;
    use Brick\Math\RoundingMode;
    
    // Cash rounding (e.g., CHF 5-cent increments)
    $money = Money::of(10, 'CHF', new CashContext(step: 5));
    
    // Custom scale (e.g., 4 decimal places)
    $money = Money::of(10, 'USD', new CustomContext(scale: 4));
    
    // Auto scale
    $money = Money::of('1.10', 'USD', new AutoContext());
    use Brick\Money\Money;
    use Brick\Money\Context\CashContext;
    use Brick\Math\RoundingMode;
    
    $money = Money::of(10, 'CHF', new CashContext(step: 5)); // CHF 10.00
    $money->dividedBy(3, RoundingMode::Down); // CHF 3.30
    $money->dividedBy(3, RoundingMode::Up); // CHF 3.35
  4. Install Brick\Money via Composer

    main

    Install the library using Composer:

    composer require brick/money

    Requirements

    • PHP 8.2+ (for current version)
    • For older PHP versions, use specific releases:
      • PHP 8.1: version 0.10
      • PHP 8.0: version 0.8
      • PHP 7.4: version 0.7
      • PHP 7.1, 7.2, 7.3: version 0.5

    Recommendation: Install the GMP or BCMath extension to improve calculation performance.

  5. Format Money objects

    main

    Requirement: Formatting requires the PHP intl extension.

    You can format Money in two ways:

    1. Locale-based: Use formatToLocale(string $locale) for quick formatting based on a standard locale.
    2. Custom Formatter: Use Brick\Money\Formatter\MoneyNumberFormatter with a standard PHP NumberFormatter instance for full control over symbols, separators, and fraction digits.

    Important: Because formatting uses NumberFormatter, the amount is converted to floating point during the process. This may cause discrepancies when formatting extremely large monetary values.

    // Locale-based formatting
    $money = Money::of(5000, 'USD');
    echo $money->formatToLocale('en_US'); // $5,000.00
    echo $money->formatToLocale('fr_FR'); // 5 000,00 $US
    
    // Custom NumberFormatter
    use Brick\Money\Money;
    use Brick\Money\Formatter\MoneyNumberFormatter;
    
    $formatter = new \NumberFormatter('en_US', \NumberFormatter::CURRENCY);
    $formatter->setSymbol(\NumberFormatter::CURRENCY_SYMBOL, 'US$');
    $formatter->setSymbol(\NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL, '·');
    $formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 2);
    
    $money = Money::of(5000, 'USD');
    echo (new MoneyNumberFormatter($formatter))->format($money); // US$5·000.00
  6. Perform high-precision calculations with RationalMoney

    main

    When chaining multiple operations, applying rounding at every step can lead to precision loss. RationalMoney solves this by storing the amount as a fraction (rational number) internally, performing exact arithmetic without any rounding.

    To use it, convert your Money instance to a RationalMoney using toRational(). After all operations are complete, convert it back to a Money instance using toContext(), where you finally specify the desired Context and RoundingMode.

    use Brick\Money\Money;
    use Brick\Math\RoundingMode;
    use Brick\Money\Context\CustomContext;
    
    $money = Money::of('9.5', 'EUR');                       // EUR 9.50 (Money)
    $money = $money->toRational()                           // EUR 19/2 (RationalMoney)
      ->dividedBy(3)                                        // EUR 19/6 (RationalMoney)
      ->plus('17.795')                                      // EUR 12577/600 (RationalMoney)
      ->multipliedBy('1.196')                               // EUR 3760523/150000 (RationalMoney)
      ->toContext($money->getContext(), RoundingMode::Down) // EUR 25.07 (Money)
    use Brick\Money\Money;
    use Brick\Math\RoundingMode;
    
    $money = Money::of('9.5', 'EUR');                       // EUR 9.50 (Money)
    $money = $money->toRational()                           // EUR 19/2 (RationalMoney)
      ->dividedBy(3)                                        // EUR 19/6 (RationalMoney)
      ->plus('17.795')                                      // EUR 12577/600 (RationalMoney)
      ->multipliedBy('1.196')                               // EUR 3760523/150000 (RationalMoney)
      ->toContext($money->getContext(), RoundingMode::Down) // EUR 25.07 (Money)
  7. Persist Money objects in a database

    main

    When storing money in a database, you should store the amount and the currency separately. There are two recommended strategies for the amount:

    1. Persisting the Amount

    • As an integer (Minor Units): Best if you always use the currency's default scale (e.g., cents for USD). Store the minor units as an integer.
      • Save: $money->getMinorAmount()->toInt()
      • Retrieve: Money::ofMinor($integerAmount, $currencyCode)
    • As a decimal: Recommended for most other cases. Store the amount as a string/decimal type.
      • Save: $money->getAmount()->toString()
      • Retrieve: Money::of($decimalAmount, $currencyCode)

    2. Persisting the Currency

    • As a string: Use CHAR(3) for ISO codes or VARCHAR for custom codes. Retrieve using $money->getCurrency()->getCurrencyCode().
    • As an integer: Use the numeric currency code. Retrieve using $money->getCurrency()->getNumericCode(). To reconstruct, use Currency::ofNumericCode($numericCode).

    3. Using an ORM (e.g., Doctrine)

    Perform conversion in your entity's getters and setters to keep the database representation simple (integer/string) while using Money objects in your application logic.

    // Example ORM Entity pattern
    class Entity
    {
        private int $price;
        private string $currencyCode;
    
        public function getPrice() : Money
        {
            return Money::ofMinor($this->price, $this->currencyCode);
        }
    
        public function setPrice(Money $price) : void
        {
            $this->price = $price->getMinorAmount()->toInt();
            $this->currencyCode = $price->getCurrency()->getCurrencyCode();
        }
    }
  8. Cache exchange rates with CachedProvider

    main

    The CachedProvider wraps an existing ExchangeRateProvider and caches results using a PSR-16 cache implementation. It caches both successful lookups and failed lookups (not-found rates).

    • TTL: You can specify a Time-To-Live in seconds.
    • Dimensions: Dimensions are included in the cache key.
    • Custom Normalizers: If you use complex objects as dimensions, you can provide a dimensionObjectNormalizer callback to convert them into cacheable scalars.
    use Brick
    Money\\ExchangeRateProvider\CachedProvider;
    
    // Using default in-memory cache
    $cachedProvider = new CachedProvider($provider);
    
    // Using custom PSR-16 cache and TTL
    $cachedProvider = new CachedProvider(
        provider: $provider,
        cache: $yourPsr16Cache,
        ttl: 3600,
    );
    
    // Using a custom normalizer for dimension objects
    $cachedProvider = new CachedProvider(
        provider: $provider,
        cache: $yourPsr16Cache,
        dimensionObjectNormalizer: function (object $value) {
            if ($value instanceof YourCustomType) {
                return $value->toKey(); // returns string, int, float, or bool
            }
            return null;
        },
    );
  9. Split a Money object into parts

    main

    You can divide a Money instance into multiple parts using the split() method. You must specify the number of parts and a SplitMode to determine how remainders are handled.

    • SplitMode::ToFirst: Distributes the remainder one step at a time to the first parts in the array.
    • SplitMode::Separate: Returns the remainder as a separate last element in the resulting array.
    use Brick\
    Money\\Money;
    use Brick
    Money\\SplitMode;
    
    $money = Money::of(100, 'USD');
    [$a, $b, $c] = $money->split(3, SplitMode::ToFirst); // USD 33.34, USD 33.33, USD 33.33
    
    // Using Separate mode
    $money->split(3, SplitMode::Separate); // [USD 33.33, USD 33.33, USD 33.33, USD 0.01]
  10. Create custom currencies

    main

    While the library supports ISO 4217 by default, you can define custom currencies using the Currency class.

    Warning: Do not create multiple Currency instances with the same currency code but different data. The library identifies currencies by code, and conflicting instances may cause undefined behavior.

    To create a custom currency, provide a code, an optional numeric code, a name, and a default scale.

    use Brick\Money\Currency;
    use Brick\Money\Money;
    
    $bitcoin = new Currency(
        'XBT',     // currency code
        null,      // numeric currency code, optional
        'Bitcoin', // currency name
        8,         // default scale
    );
    
    $money = Money::of('0.123', $bitcoin); // XBT 0.12300000
  11. Compare Money instances

    main

    You can compare Money instances using several methods. These methods accept either a number or another Money instance.

    Comparison Methods

    • compareTo($amount): Returns -1, 0, or 1.
    • isEqualTo($amount): Returns true if values and currencies match.
    • isGreaterThan($amount)
    • isGreaterThanOrEqualTo($amount)
    • isLessThan($amount)
    • isLessThanOrEqualTo($amount)

    Important: Standard comparison methods (like isEqualTo) throw a CurrencyMismatchException if the currencies do not match. To compare amount and currency without throwing an exception, use isSameValueAs().

    $oneEuro = Money::of(1, 'EUR');
    
    $oneEuro->isEqualTo(Money::of(1, 'EUR')); // true
    $oneEuro->isEqualTo(Money::of(1, 'USD')); // CurrencyMismatchException
    
    $oneEuro->isSameValueAs(Money::of(1, 'EUR')); // true
    $oneEuro->isSameValueAs(Money::of(1, 'USD')); // false
    $oneEuro = Money::of(1, 'EUR');
    
    $oneEuro->isEqualTo(Money::of(1, 'EUR')); // true
    $oneEuro->isEqualTo(Money::of(2, 'EUR')); // false
    $oneEuro->isEqualTo(Money::of(1, 'USD')); // CurrencyMismatchException
    
    $oneEuro->isSameValueAs(Money::of(1, 'EUR')); // true
    $oneEuro->isSameValueAs(Money::of(2, 'EUR')); // false
    $oneEuro->isSameValueAs(Money::of(1, 'USD')); // false
  12. Use BaseCurrencyProvider for relative rates

    main

    If all your exchange rates are relative to a single base currency (e.g., EUR), use BaseCurrencyProvider. It wraps another provider and automatically calculates cross-rates (e.g., converting USD to GBP via EUR).

    use Brick
    Money\\ExchangeRateProvider\ConfigurableProvider;
    use Brick
    Money\\ExchangeRateProvider\BaseCurrencyProvider;
    
    $provider = ConfigurableProvider::builder()
        ->addExchangeRate('EUR', 'USD', '1.1')
        ->addExchangeRate('EUR', 'GBP', '0.9')
        ->build();
    
    $provider = new BaseCurrencyProvider($provider, 'EUR');
    // Now you can get rates for USD to GBP indirectly
    $rate = $provider->getExchangeRate(Currency::of('USD'), Currency::of('GBP'));