user@elrise.io:~/finance-money-bundle
· [active]

finance-money-bundle — Money value objects and BCMath for Symfony fintech

→ репозиторий
Symfony bundle for type-safe monetary value objects backed by BCMath arithmetic, a currency registry with Fiat/Crypto/Custom enum split (host application assembles its own set), an exchange-rate provider port via tagged services, and hot-path budgets under 100 µs for high-load financial systems.

Overview

Finance Money Bundle is a Symfony bundle for high-load financial systems where monetary calculations run through ext-bcmath and float drift is unacceptable. The bundle provides an immutable Money value object with type-safe arithmetic, a currency registry with Fiat / Crypto / Custom enum split (no bundled default — the host application assembles its own set via Currency::iso/crypto/custom), an ExchangeRateProviderInterface port for injecting regional rate providers, and hot-path budgets under 100 µs validated via phpbench.

The bundle follows the same model as elriseio/dbal-bundle: a framework-agnostic core plus a Symfony Bundle integration on top. The core layer does not depend on the Symfony container and can be reused in framework-free PHP applications; the Bundle layer loads services.php and configures the ExchangeRateProviderPass compiler pass to select the rate provider among tagged services.

This document is a reference for the bundle itself: contracts, components, hot-path budgets, quality gates. The architectural motivation — why BCMath-only and type-safe value objects for money in PHP, rather than int cents or float — is in a separate note (planned for Wave 0+).

Quick start

Minimum scenario: install the bundle, register it, run arithmetic via Money::of.

composer require elriseio/finance-money-bundle
// config/bundles.php
return [
    Elrise\Finance\Bundle\Money\ElriseFinanceMoneyBundle::class => ['all' => true],
];
use Elrise\Finance\Bundle\Money\Money;
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Currency\CurrencyRegistry;

$registry = CurrencyRegistry::mutable([
    Currency::iso('USD', '840'),
    Currency::iso('EUR', '978'),
    Currency::crypto('BTC', 8),
    Currency::crypto('ETH', 18),
    Currency::custom('POINTS', 0),
]);
$usd = $registry->get('USD');

$price = Money::of('100.00', $usd);
$tax   = $price->multipliedBy('0.20');     // 20.00 USD
$total = $price->plus($tax);              // 120.00 USD

echo $total->format();                   // "120.00 USD"
echo $total->canonicalString();          // "120.00"
echo $total->minorUnits();               // 12000

The bundle ships no default registry. The host application assembles the registry at boot (see §Currency registry) and, when needed, installs it through MoneyRegistry::setDefault($registry) — after that, Money::of('100.00', 'USD') resolves the string code through the default registry.

Full configuration, cross-currency conversion, hot-path budgets, and quality gates are below.

What it is and what it is not

The bundle does:

The bundle does not:

Compatibility

Component Version
PHP 8.3, 8.4, or 8.5 (strict types)
ext-bcmath required
Composer 2.x
Symfony 7.x or 8.x (for the Bundle layer; core is framework-agnostic)

PHP 8.2 is not supported. CI runs the matrix PHP 8.3 / 8.4 / 8.5 × Symfony 7.x / 8.x.

Architecture

The core layer is framework-agnostic: Money, Currency, Decimal\Math, CurrencyRegistry, and the ExchangeRateProviderInterface / CurrencyRegistryInterface contracts live without a Symfony dependency. The Bundle layer is a separate Symfony\Component\DependencyInjection config plus compiler passes plus attribute registration.

                     ┌────────────────────────────────┐
                     │  elriseio/finance-money-bundle │
                     │  (framework-agnostic core +   │
                     │   Symfony Bundle integration)│
                     └─────────────┬──────────────────┘
                                   │
                ┌──────────────────┼─────────────────────┐
                │                  │                     │
        ┌───────▼────────┐ ┌────────▼────────┐    ┌───────▼────────┐
        │ Money /        │ │ Decimal\Math   │    │ CurrencyReg.  │
        │ Currency VO    │ │ (BCMath facade)│    │ (ISO + custom)│
        └───────┬────────┘ └────────┬────────┘    └───────┬────────┘
                │                  │                     │
                └──────────────────┼─────────────────────┘
                                   │
                     ┌─────────────▼──────────────────┐
                     │  Symfony DI / Bundle            │
                     │  (services, compiler passes,    │
                     │   config validation)            │
                     └─────────────┬──────────────────┘
                                   │
┌─────────────▼──────────────────┐
                      │  Host-side integrations        │
                      │  (Doctrine Type, Symfony       │
                      │  Serializer, Forms DataTrans-  │
                      │  former, custom rate provider) │
                      └────────────────────────────────┘

Contracts

Symfony Bundle

Installation

composer require elriseio/finance-money-bundle

Register the bundle in config/bundles.php:

return [
    // ...
    Elrise\Finance\Bundle\Money\ElriseFinanceMoneyBundle::class => ['all' => true],
];

The bundle does not ship a Configuration class in this version — FinanceMoneyExtension::load() only loads services.php. Provider configuration is via tagged services and the compiler pass, not via an elrise_finance_money.* section.

DI wiring for a custom rate provider in services.yaml (override of the default InMemoryExchangeRateProvider via priority):

services:
    App\ExchangeRate\EcbRateProvider:
        arguments:
            $httpClient: '@app.ecb_http_client'
        tags:
            - { name: 'finance_money.exchange_rate_provider', priority: 100 }

The ExchangeRateProviderPass selects the candidate with the highest priority at container compile time; InMemoryExchangeRateProvider stays as the fallback when no custom provider is registered (priority 0).

Money value object

Money is an immutable final readonly class. Every arithmetic operation returns a new instance. The amount is stored as a BCMath-precise decimal string; bcdiv / bcmod / bcscale outside Decimal\Math is blocked by a custom PHPStan rule.

Construction

use Elrise\Finance\Bundle\Money\Money;
use Elrise\Finance\Bundle\Money\Currency\CurrencyRegistry;
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Currency\CurrencyType;

$btc = new Currency('BTC', 8, CurrencyType::Crypto, '₿', 'Bitcoin');
$registry = CurrencyRegistry::fromCatalogue([$btc], catalogueVersion: 1);

$money = Money::of('100.00', $btc);
$money = Money::of('100.00', 'BTC');             // string code, when Money::setDefaultRegistry() has been called
$money = Money::of(100.00, $btc);                // ⚠️ float is allowed only as a transient parse target

The factories Money::zero($currency), Money::fromMinor(int $minorUnits, Currency $currency), Money::fromCanonicalString(string $value, Currency $currency), and Money::tryFromAny(mixed $input, Currency $currency) cover the typical initialisation scenarios from different sources.

Arithmetic

$deposit = Money::of('100.00', $usd);

$bonus   = $deposit->multipliedBy('1.10');        // '110.00'
$total   = $deposit->plus($bonus);                // '110.00 USD'
$net     = $total->minus(Money::of('15.00', $usd));
$half    = $total->dividedBy('2');                // '55.00' (largest-remainder)
$shares  = $total->allocate(3);                   // ['36.67', '36.67', '36.66']

All arithmetic methods return a new instance. Money::allocate(int $parts) distributes the amount with the largest-remainder method — the sum of parts equals the original amount up to the currency's scale.

Cross-currency operations

Cross-currency plus / minus / multipliedBy is a compile-time error at the type level: CurrencyMismatchException is thrown before any BCMath code runs. This is explicit: in financial code, a silent plus of USD onto EUR is not a bug to debug at 3am.

Cross-currency comparison (Money::compare) is opt-in with an explicit provider:

$sign = $usdMoney->compare($eurMoney, $rateProvider);
// -1 / 0 / 1 — the result of comparison after conversion through the provider

Without a provider, compare throws MissingExchangeRateProviderException on cross-currency input. Single-currency compare always works without a provider.

Formatting

$total = Money::of('110.00', $usd);
$fmt   = $total->format();                          // '110.00 USD'
$float = $total->toFloat();                        // 110.0 (transient parse target, not for storage)

format() returns "<canonical> <ISO-4217-code>". The bundle does not use \NumberFormatter and does not depend on ext-intl — the $locale parameter is declared for future extension but is currently unset($locale). For locale-aware symbol rendering, pass through Money::beautify($canonical, $precision, $symbol) from your per-locale catalogue.

Currency registry

CurrencyRegistryInterface is a registry with two construction modes: CurrencyRegistry::fromCatalogue(array $catalogue, int $catalogueVersion) for an immutable registry (rejects register with ImmutableRegistryException), and CurrencyRegistry::mutable(array $seed = [], int $catalogueVersion = 0) for a mutable registry that accepts register(...).

The bundle ships no default registry. The host application assembles it at boot:

use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Currency\CurrencyRegistry;
use Elrise\Finance\Bundle\Money\Currency\CurrencyType;

$registry = CurrencyRegistry::mutable([
    Currency::iso('USD', '840'),
    Currency::iso('EUR', '978'),
    Currency::iso('JPY', '392'),     // zero-decimal fiat, scale 0
    Currency::crypto('BTC', 8),
    Currency::crypto('ETH', 18),     // wei-scale
    Currency::custom('POINTS', 0),   // loyalty points
]);

$registry->register(Currency::iso('GBP', '826'));   // add later

For an immutable registry with a compiled catalogue:

$catalog = CurrencyRegistry::fromCatalogue(
    catalogue: [...],                 // list<Currency>
    catalogueVersion: 42,
);
$catalog->isMutable();                // false — register() throws
$catalog->catalogueVersion();         // 42

Lookup

$usd    = $registry->get('USD');                 // throws UnknownCurrencyException
$maybe  = $registry->tryGet('XYZ');              // ?Currency, null on miss
$hasBtc = $registry->has('USD');                 // bool
$all    = $registry->all();                      // list<Currency>
$crypto = $registry->byType(CurrencyType::Crypto); // map<string, Currency>

Currency factories

Three static factories cover the typical build scenarios. symbol and name are consumer-supplied presentation metadata; the bundle never reads them itself — the host application passes its own per-locale symbols at the UI edge.

Currency::iso('USD', '840');                   // code + numeric (Fiat, scale=0)
Currency::crypto('ETH', 18);                   // code + scale
Currency::custom('POINTS', 0);                 // code + scale

// Optional symbol and name (default = code):
Currency::iso('USD', '840', '$', 'US Dollar');
Currency::crypto('BTC', 8, '₿', 'Bitcoin');

Semantic invariants:

All codes are upper-cased and validated as ASCII-alphanumeric on construction (src/Currency/Currency.php:159-174).

Setting the default registry

Money::of(string, $currency) accepts either a Currency instance or a string code. The string path resolves through a process-wide default registry, set via:

use Elrise\Finance\Bundle\Money\MoneyRegistry;

MoneyRegistry::setDefault($registry);

// or the equivalent shortcut:
Money::setDefaultRegistry($registry);

// Now Money::of('100.00', 'USD') resolves 'USD' through $registry.

In a Symfony application the default registry is installed manually through MoneyRegistry::setDefault($registry) on a KernelEvents::REQUEST listener (or equivalent boot hook). The #[AsCurrency] attribute mentioned in USAGE.md is not published in the current src/ tree (see §Symfony Bundle wiring).

type ∈ {Fiat, Crypto, Custom} is an enum distinction available at the type level (src/Currency/CurrencyType.php). The registry holds state in a hash-map and is thread-safe for long-running workers (RoadRunner, Swoole, FrankenPHP) without additional synchronisation.

Decimal engine and BCMath boundaries

All arithmetic operations go through Decimal\Math (src/Decimal/Math.php) — a thin BCMath facade with add, sub, mul, div, cmp, quantize. Methods take canonical numeric strings and int scale, never round-trip through PHP float. If ext-bcmath is unavailable, Math::assertBcMath() throws BcmMathUnavailableException on the first call (cached afterwards).

Rounding modes (RoundingMode)

src/Decimal/RoundingMode.php is an enum with seven cases for banker's rounding and engineering scenarios:

Case Semantics Example (2.5)
HALF_UP round half towards positive infinity (IEEE 754 "round half up") 3
HALF_EVEN banker's rounding, round half to even 2
HALF_DOWN round half towards zero 2
DOWN truncate toward zero 2.9 → 2
UP round away from zero 2.1 → 3
CEILING round toward positive infinity -2.9 → -2
FLOOR round toward negative infinity -2.1 → -3

HALF_UP is the default in Money::* arithmetic (multipliedBy, dividedBy, convert always quantise via HALF_UP).

ForbiddenBcFunctionInDecimalRule

Direct calls to bcdiv, bcmod, bcscale from anywhere in the Elrise\Finance\Bundle\Money\Decimal\ namespace — except the dedicated Decimal\Math facade — are blocked by the custom PHPStan rule ForbiddenBcFunctionInDecimalRule (tests/PHPStan/Rules/ForbiddenBcFunctionInDecimalRule.php):

final class ForbiddenBcFunctionInDecimalRule implements Rule
{
    private const FORBIDDEN = ['bcdiv', 'bcmod', 'bcscale'];
    private const FACADE = 'Elrise\\Finance\\Bundle\\Money\\Decimal\\Math';
    private const NAMESPACE_PREFIX = 'Elrise\\Finance\\Bundle\\Money\\Decimal';
    // processNode() raises an `elriseMoney.forbiddenBcFunction` rule error
}

bcadd, bcsub, bcmul, bccomp are not in the forbidden set — the engine routes them through Math and the rule keeps scope tight to the three forbidden functions. The facade itself calls bcdiv legitimately; the rule permits it via a class-name allow-list.

This is not "PHP 8.4 does not support bcdiv" — it is an architectural decision: all BCMath semantics live in Decimal\Math, and any attempt to call BCMath directly from code in the Elrise\Finance\Bundle\Money\Decimal\ namespace is treated as a code smell and blocked at the analyzer level.

Float in hot path

Additional custom PHPCS protection against float in hot-path files (src/Money.php, src/Currency/, src/Decimal/):

Float is allowed as a transient parse targetMoney::of(float, Currency) parses into a BCMath string at input and never stores float. This closes the class of bugs where "we passed float and it drifted during arithmetic".

Cross-currency conversion

Cross-currency conversion goes through the ExchangeRateProviderInterface port. The bundle exposes two methods:

ConversionResult is a final readonly DTO with public getters from(), to(), rate(): string, source(): ?string, at(): ?DateTimeImmutable. source and at are propagated from provider->rateWithMetadata().

use Elrise\Finance\Bundle\Money\Money;
use Elrise\Finance\Bundle\Money\ValueObject\ConversionResult;

$total = Money::of('100.00', $usd);

/** @var ConversionResult $result */
$result = $total->convertWithMetadata(
    $eur,
    $rateProvider,
    new \DateTimeImmutable('2026-07-27T12:00:00+00:00'),
);

// $result->from()    === Money('100.00', USD)
// $result->to()      === Money(95.85, EUR)
// $result->rate()    === '0.9585'
// $result->source()  === 'ecb' (from the provider; null for InMemoryExchangeRateProvider)
// $result->at()      === DateTimeImmutable(...)

Conversion audit is the first thing a financial regulator asks for: who gave the rate, at what moment. source = null is a valid state for providers that do not have a logical name (for example, InMemoryExchangeRateProvider).

Exchange rate providers

ExchangeRateProviderInterface is the port for rate providers. The real signature (src/Contract/ExchangeRateProviderInterface.php):

namespace Elrise\Finance\Bundle\Money\Contract;

use DateTimeImmutable;
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Decimal\Decimal;
use Elrise\Finance\Bundle\Money\ValueObject\ConversionResult;

interface ExchangeRateProviderInterface
{
    public function getRate(
        Currency $from,
        Currency $to,
        DateTimeImmutable $at,
    ): ?Decimal;

    public function rateWithMetadata(
        Currency $from,
        Currency $to,
        DateTimeImmutable $at,
    ): ConversionResult;
}

getRate() returns Decimal|null — a multiplicative rate r, quantised to RATE_SCALE, or null when the pair is unavailable. Money::convert() translates null into ExchangeRateUnavailableException.

The bundle registers InMemoryExchangeRateProvider as the default through tagged services (src/Resources/config/services.php):

$services->set(InMemoryExchangeRateProvider::class, InMemoryExchangeRateProvider::class)
    ->args([[]]) // rates map; host applications override via service decoration or a custom provider
    ->public()
    ->tag(ExchangeRateProviderPass::TAG_NAME, ['priority' => 0]);

InMemoryExchangeRateProvider accepts an empty rates map by default and serves as the fallback: the ExchangeRateProviderPass compiler pass selects the candidate with the highest priority from all tagged services. The host application registers its own provider through tags: [{ name: 'finance_money.exchange_rate_provider', priority: 100 }], and it overrides the default.

Concrete regional providers (CbrRateProvider, EcbRateProvider, BinanceRateProvider, ...) are built by the host application — no separate companion package exists in the current version. Implementations must be immutable and free of hidden I/O on the monetary hot path (caching, aggregation, and freshness checks are outside the contract).

Hot-path budgets

Benchmarking is two-layered (bench/ and itests/) and neither replaces the budget table from the README, because bench/ contains only DecimalBench::benchPlusScaleEight() (bench/DecimalBench.php), which measures Decimal::plus throughput at scale 8. This is an advisory harness — not an enforced gate: composer bench is opt-in and does not run in the CI smoke gate (itests-smoke.yml). The throughput gate is planned for Wave 4 and is not activated in the current version.

Real hot-path coverage in the current version:

The budget table from the README (Money::of < 100 µs, Money::plus < 50 µs, Money::multipliedBy < 100 µs, etc.) is a target spec, not measured numbers. Until Wave 4 these numbers are not confirmed by benchmarks and are not enforced in CI; throughput regressions on the current version will pass silently. Hosts for whom per-call latency matters should run composer bench in their own environment and measure their actual hot path.

Quality gates and testing

The full quality-gate set:

composer test          # PHPUnit (Unit / Contract / Property-based)
composer stan          # PHPStan level 8 + ergebnis rules
composer cs            # PHPCS (PSR-12 + custom Money standard)
composer bench         # phpbench micro-benchmarks
composer guard         # stan + cs + property (full quality gate)
composer itests:envelope # itests envelope contract

All four commands exit non-zero on failure. CI runs the matrix on PHP 8.3, 8.4, 8.5 against Symfony 7.x and 8.x.

Custom rules

Test suites

Suite Location Purpose
Unit tests/Unit/ Pure unit tests, no Symfony container
Contract tests/Contract/ Public-interface invariants; cross-implementation consistency
Property-based tests/Property/ Round-trip and invariant fuzzing via Eris
Integration itests/ End-to-end with real Symfony container, in-memory rate provider, scenario runners
Benchmark bench/ phpbench micro-benchmarks for hot-path budgets
Failure-mode itests/FailureMode/ Cache-down, provider-down, BCMath-scale-overflow

Property-based tests assert algebraic invariants:

Get hands-on

dummy-market-agent is a reference Symfony service where elriseio/finance-money-bundle, elriseio/application-layer-bundle, elriseio/dbal-bundle, and API Platform are wired together into one runnable application. It includes DI wiring, hot-path scenarios, an exchange-rate port with a test provider, and persistence integration through host-owned Doctrine Type / Serializer Normalizer.

The repository runs through Docker; commands and scenarios are described in the repo README.

Known limitations

Sources