finance-money-bundle — Money value objects and BCMath for Symfony fintech
A Symfony bundle for high-load financial systems: type-safe Money and Currency value objects backed by BCMath, 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 validated via phpbench.
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:
- expose
MoneyandCurrencyasfinal readonlyvalue objects; every arithmetic operation (plus,minus,multipliedBy,dividedBy,allocate,compare) returns a new instance — immutability enforced at the type level; - store amounts as BCMath-precise decimal strings via the
Decimal\Mathfacade; directbcdiv/bcmod/bcscaleoutsideDecimal\is blocked by the custom PHPStan ruleForbiddenBcFunctionInDecimalRule; - match currencies at the type level: cross-currency
plus/minusis a compile-time error (CurrencyMismatchException); cross-currency comparison viaMoney::compare(other, ?provider)is opt-in with an explicit provider; - ship
CurrencyRegistryInterfacewithhas/get/tryGet/all/byType/register/catalogueVersion/isMutable; currency type is theCurrencyTypeenum with three casesFiat/Crypto/Custom. The bundle ships no default registry — the host application assembles it at boot viaCurrencyRegistry::mutable([...])orCurrencyRegistry::fromCatalogue(...)(see §Currency registry); - expose
ExchangeRateProviderInterfacevia DI as a tagged servicefinance_money.exchange_rate_provider; theExchangeRateProviderPasscompiler pass picks the highest-priority candidate.InMemoryExchangeRateProvideris registered as the default (priority 0); concrete providers (CbrRateProvider,EcbRateProvider,BinanceRateProvider, etc.) are not in core — the host application implements them on top ofExchangeRateProviderInterface(see §MoneyConverter service → Custom provider implementation); - return a
ConversionResultDTO with publicfrom()/to()/rate()/source()/at()for conversion audit; opt-in viaMoney::convertWithMetadata(); the simplerMoney::convert()remains for the basic use case; - register a Symfony Bundle that loads
services.phpand configures theExchangeRateProviderPasscompiler pass. The bundle does not ship a Configuration class — provider configuration is via tagged services, not via anelrise_finance_money.*section; - validate hot-path budgets via
phpbench(see §Hot-path budgets); regressions fail CI; - emit structured exceptions through a single
DomainException(parent ofCurrencyMismatchException,UnknownCurrencyException,ExchangeRateUnavailableException,DivisionByZeroException,InvalidAmountException,MathScaleException,ImmutableRegistryException,FundConversionOverflowException,InvalidExternalMultiplierException,BcmMathUnavailableException); integration with PSR-3 loggers viaThrowableRenderer.
The bundle does not:
- does not provide a Doctrine Type for
Money— the host application implements one on its own side viaType::convertToPHPValue+convertToDatabaseValue, usingMoney::canonicalString()/fromCanonicalString()(orminorUnits()for integer columns); - ship exchange-rate providers in core — only the
ExchangeRateProviderInterfacecontract. Implementations (CbrRateProvider,EcbRateProvider, …) are built by the host application (see §MoneyConverter service → Custom provider implementation); - ship an HTTP client for rate APIs — the DI provider receives its HTTP client from the consumer container;
- provide UI formatting beyond what the built-in
Money::format()(which returnscanonical + space + ISO-4217 code) covers — for locale-aware rendering, pass the symbol throughMoney::beautify(value, precision, symbol)from your per-locale catalogue; - support PHP 8.2 — the bundle uses
final readonly class,enumcases, and typed constants; - promise zero-cost abstraction — every
Money::plusgoes throughbcaddat the receiver’s scale. This is ~50 µs per operation, which fits the hot-path budget but does not suit high-throughput hot paths > 10k ops/sec (see §Known limitations).
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
Money(final readonly) — BCMath-backed value object; static factoriesMoney::of(string|int, Currency|string), arithmetic returns a new instance;format(?string $locale = null)returns"<canonical> <code>"(locale is currently unset, future extension point);beautify(string $value, int $precision, string $symbol): stringreturns"<symbol> <value>"verbatim.Currency(final readonly) —(code, scale, type, symbol, name);type ∈ {Fiat, Crypto, Custom}.Decimal\Math— BCMath facade; directbcdiv/bcmod/bcscaleoutsideDecimal\is blocked byForbiddenBcFunctionInDecimalRule.CurrencyRegistryInterface—get(string): Currency(throws),tryGet(string): ?Currency(soft fail),register(Currency): void.ExchangeRateProviderInterface— DI-only contract; implementations (CbrRateProvider,EcbRateProvider, …) are built by the host application.ConversionResult— DTO(from, to, rate, source, at); opt-in viaMoney::convertWithMetadata().
Symfony Bundle
- Config key
elrise_finance_money. Supports thedefault_currency,default_multiplier,bcmath_scale,view_precision,rounding_mode,currencies,custom_currencies,formatter_locale, andexchange_rate_providersections. #[AsCurrency]attribute mentioned in USAGE.md as a way to discover custom currencies, but not yet published in the currentsrc/tree (src/Attribute/is empty); see §Symfony Bundle wiring for current-state workaround.ExchangeRateProviderPasscompiler pass — selects theExchangeRateProviderInterfaceamong tagged services with the highestpriorityat container compile time; default isInMemoryExchangeRateProviderat priority 0.- Auto-wired services:
MoneyConvertervia autowiring, plus tagged services for rate providers.
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:
Currency::iso()— alpha-3 code of three ASCII letters + optional numeric-3; scale hard-coded 0 for all fiat (USD/EUR/JPY are integer units in this model);Currency::crypto()— code 3-12 ASCII characters; scale 0-18 (typically BTC=8, ETH=18);Currency::custom()— same as crypto, but enum caseCustom(POINTS, bonus points, in-game credits).
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/):
Money.HotPath.NoFloatOnMoneyHotPath— fails onfloatin method signatures, return types, or property declarations in hot-path files.Money.HotPath.ImmutabilityGuard— fails if a value object in hot-path files is missingfinalorreadonly, declares a non-readonly property, or exposes a public setter.
Float is allowed as a transient parse target — Money::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:
Money::convert(Currency $target, ExchangeRateProviderInterface $provider, ?\DateTimeImmutable $at = null): self— basic, returns the convertedMoney. ThrowsCurrencyMismatchExceptionwhen$this->currencyand$targetare the same currency;ExchangeRateUnavailableExceptionwhen the provider returnsnullfor the pair.Money::convertWithMetadata(Currency $target, ExchangeRateProviderInterface $provider, ?\DateTimeImmutable $at = null): ConversionResult— opt-in with metadata audit. Wrapsconvert()and additionally returns aConversionResultwithrate,source, andat.
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:
bench/— synthetic micro-benchmark forDecimal\Decimal. Measures per-call cost on hand-crafted shapes (Decimal::of('12345678.12345678', 8)->plus(...)× 3). Does not measureMoney::*methods.itests/— integration load-testing under seeded multi-worker load through canonical scenarios (decimal_arithmetic,currency_conversion,fund_converter,exchange_rate_cache). The CI smoke gate runsitests/, notbench/.
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
Money.HotPath.NoFloatOnMoneyHotPath(PHPCS) — fails onfloatin signature/return-type/property declaration insrc/Money.php,src/Currency/,src/Decimal/.Money.HotPath.ImmutabilityGuard(PHPCS) — fails if a value object in hot-path files is missingfinalorreadonly, declares a non-readonly property, or exposes a public setter.ForbiddenBcFunctionInDecimalRule(PHPStan) — blocks directbcdiv/bcmod/bcscaleinDecimal\outsideDecimal\Math.
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:
add(a, b) == add(b, a)multiply(a, 1) == aallocate(n)sums to originalfromCanonicalString(toCanonicalString(x)) == x(round-trip)
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
- High-throughput hot path > 10k ops/sec.
Money::plusgoes throughbcadd(~30-50 µs). If your hot path runs tens of thousands of operations per second per worker, use nativeint cents/int satoshiand convert toMoneyonly at layer boundaries. The bundle is not designed for that. - PHP 8.2 is not supported. The bundle uses
final readonly class,enumcases, and typed constants. The minimum is 8.3. - Concrete exchange-rate providers are not in core. Implementations of
CbrRateProvider,EcbRateProvider,BinanceRateProviderare built by the host application on top ofExchangeRateProviderInterface(see §MoneyConverter service → Custom provider implementation). - Locale-aware formatting is not built in.
Money::format()returns"<canonical> <ISO-code>". For locale-aware symbol rendering, the host callsMoney::beautify($canonical, $precision, $symbol)with a per-locale symbol catalogue. - Float in the hot path is blocked at the analyzer level. If you have legacy code that passes float directly to
Moneysignatures, migration requires explicitMoney::of(float, $currency)wrapping. This is by design: float in monetary arithmetic is a source of drift, not of performance. - Currency catalogue is host-owned. The bundle ships no bundled catalogue; the host application assembles its currency set at boot (see §Currency registry). If a canonical ISO map is needed, an out-of-band package with ISO data is recommended — this is out of scope for the current bundle version.
Sources
- github.com/elriseio/finance-money-bundle — repository, MIT.
- packagist.org/packages/elriseio/finance-money-bundle — Packagist.
- PHP BCMath documentation —
ext-bcmathreference. - Symfony Bundle Best Practices — Bundle layer structure.
- ISO 4217 — currency standard; the bundle does not track it (see §Known limitations).
- Sibling project — elriseio/dbal-bundle: a DBAL manager for high-load Symfony, on the same framework-agnostic core + Symfony Bundle integration model.