elriseio / finance-money-bundle
Type-safe monetary value objects, currency registry, exchange-rate port, and Symfony Bundle integration for high-load financial applications.
Package info
github.com/elriseio/finance-money-bundle
Type:symfony-bundle
pkg:composer/elriseio/finance-money-bundle
Requires
- php: ^8.3
- ext-bcmath: *
Requires (Dev)
- ergebnis/phpstan-rules: ^2
- giorgiosironi/eris: ^1.1
- phpbench/phpbench: ^1.2
- phpstan/phpstan: ^2.1.35
- phpunit/phpunit: ^11.5
- squizlabs/php_codesniffer: ^3.7
- symfony/config: ^8.0
- symfony/dependency-injection: ^8.0
- symfony/http-kernel: ^8.0
This package is auto-updated.
Last update: 2026-07-28 08:31:07 UTC
README
Type-safe monetary value objects, currency registry, exchange-rate port, and Symfony Bundle integration for high-load financial applications.
Money Bundle is a Symfony-native bundle for high-load financial applications. It provides a type-safe monetary value object layer for PHP 8.3+ applications. Every arithmetic operation goes through BCMath; currency mismatch is a compile-time error; the exchange-rate port is injected through DI so consumers stay in control of regional API choice.
The bundle is part of the elriseio/ vendor namespace alongside the sibling elriseio/dbal-bundle. The composer name elriseio/finance-money-bundle is the canonical identifier on Packagist and in the project's composer.json name field.
What you get
Moneyvalue object — immutable,final readonly, BCMath-backed. Static factories accept a string code orCurrencyinstance; arithmetic (plus,minus,multipliedBy,dividedBy,allocate,compare) returns new instances. Optional cross-currency comparison viaMoney::compare(other, ?provider)(DE-020).Currencyvalue object — code, scale, type (Fiat/Crypto/Custom), symbol, name.Decimalengine — BCMath-only facade. Directbcdiv/bcmod/bcscaleoutsideDecimal\Mathis forbidden by a custom PHPStan rule (ForbiddenBcFunctionInDecimalRule).FundConverter— float ↔ string ↔ int ↔ fund withexternalMultiplierfor PSP cents / satoshi / virtual-currency units.ExchangeRateProviderInterface— DI-only contract; concrete providers (CbrRateProvider,EcbRateProvider, …) are not part of this bundle's core. They live in optional companion packages. Provider metadata (source,at) is exposed through therateWithMetadata()method (DE-021); the simplerrate()method stays for backwards compatibility.ConversionResultDTO — carries(from, to, rate, source, at)for conversion audit (DE-021). Returned byMoney::convertWithMetadata()(opt-in sibling method).CurrencyRegistryInterface— registry of currencies with soft-fail lookup (tryGetreturnsnull;getthrowsUnknownCurrencyException). The bundle ships no default registry; host applications populate the registry at boot time viaCurrencyRegistry::mutable()->register(...)(orCurrencyRegistry::fromCatalogue(...)for an immutable seed).InMemoryExchangeRateProvider— a stub provider shipped in the core for tests; never use in production.- Symfony Bundle —
elrise_finance_moneyconfig key, services auto-wired, attribute-based registration (#[AsCurrency]),ValidateConfigurationPasscompiler pass. - Strict exception taxonomy — every public method has a
declared
@throwsset; domain exceptions carry anerrorCode()for structured logging.
When to use this
- Multi-currency fintech applications: iGaming, Banking, Lending, Crypto, PSP, Neobank.
- Any system where BCMath is non-negotiable and float drift is unacceptable.
- Symfony 7.x or 8.x projects that want first-class monetary contracts without giving up framework independence for the core.
- Codebases that need
Money/Currencyvalue objects but want consumer code to not import BCMath directly.
When NOT to use this
- Mono-currency apps where
int centsis enough — theMoneyvalue object adds overhead you do not need. - High-throughput hot paths (> 10k ops/sec) where the BCMath cost
matters more than type safety — use a native
intrepresentation and store amounts in minor units. - Pure presentation work — the bundle's
MoneyFormatteruses\NumberFormatterviaext-intl. For non-monetary display, useNumberFormatterdirectly.
Requirements
| Requirement | Version | Mandatory |
|---|---|---|
| PHP | 8.3, 8.4, or 8.5 | yes |
ext-bcmath |
any | yes |
ext-intl |
any | optional (used by MoneyFormatter) |
| Composer | 2.x | yes |
| Symfony | 7.x or 8.x | yes (for the Bundle layer; core is framework-agnostic) |
PHP 8.2 is not supported (the bundle uses final readonly class,
enum cases, and typed constants).
Installation
composer require elriseio/finance-money-bundle
Register the bundle in config/bundles.php (Symfony Flex does this
automatically):
return [ // ... Elrise\Finance\Bundle\Money\FinanceMoneyBundle::class => ['all' => true], ];
Quick start
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', null, 'US Dollar'), Currency::iso('EUR', '978', null, 'Euro'), Currency::crypto('BTC', 8, null, 'Bitcoin'), Currency::crypto('ETH', 18, null, 'Ether'), Currency::custom('POINTS', 0, null, 'Bonus Points'), ]); $usd = $registry->get('USD'); $deposit = Money::of('100.00', $usd); $bonus = $deposit->multipliedBy('1.10'); // '110.00' $total = $deposit->plus($bonus); // '110.00 USD' $fmt = $total->format('ru_RU'); // '110.00 USD' // Cross-currency conversion with metadata (DE-021) $result = $total->convertWithMetadata( 'EUR', new \DateTimeImmutable(), $rateProvider, ); // $result->to() === Money(95.85, EUR) // $result->rate() === '0.9585' // $result->source() === 'ecb' (provider-supplied) // $result->at() === DateTimeImmutable(...) // Cross-currency comparison (DE-020) $sign = $usdMoney->compare($eurMoney, $rateProvider); // -1 / 0 / 1
A minimal Symfony services.yaml excerpt:
services: Elrise\Finance\Bundle\Money\Contract\ExchangeRateProviderInterface: alias: 'app.exchange_rate.ecb_provider' # from your project Elrise\Finance\Bundle\Money\CurrencyRegistry: arguments: $currencies: '%elrise_finance_money.currencies%' _instanceof: Elrise\Finance\Bundle\Money\Contract\CurrencyInterface: tags: ['elrise_finance_money.currency']
Configuration
elrise_finance_money: enabled: true default_currency: USD default_multiplier: 100000 # internal precision (5 fractional digits) bcmath_scale: 8 # safety limit for BCMath operations view_precision: 2 # precision for UI formatting rounding_mode: half_up # half_up | half_even | half_down # The bundle ships no default currency registry. Host # applications populate the registry at boot time via # CurrencyRegistry::mutable()->register(...). The currency # metadata below documents the recommended scale/type per # code; symbols are consumer-supplied at the UI edge. exchange_rate_provider: 'app.exchange_rate.ecb_provider'
Quality gates
Run the full battery locally before opening a PR:
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
All four commands exit non-zero on failure. CI runs the same matrix
on PHP 8.3, 8.4, and 8.5; Composer resolves symfony/* to the
highest stable branch that matches the platform PHP
(Symfony 7.x on PHP 8.3, Symfony 7.x or 8.x on PHP 8.4, Symfony 8.x
on PHP 8.5).
Custom PHPCS sniffs (tests/Rules/Money/):
Money.HotPath.NoFloatOnMoneyHotPath— fails onfloatparameter, return type, or property undersrc/Money.php,src/Currency/, orsrc/Decimal/.Money.HotPath.ImmutabilityGuard— fails if a value object insrc/Money.php,src/Currency/Currency.php, orsrc/Decimal/Decimal.phpis missingfinalorreadonly, declares a non-readonly property, or exposes a public setter.
Custom PHPStan rule:
ForbiddenBcFunctionInDecimalRule— blocks directbcdiv,bcmod,bcscalecalls inDecimal\outsideDecimal\Math.
Testing
| Test suite | Where | 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 (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 like
add(a, b) == add(b, a), multiply(a, 1) == a,
allocate(n) sums to original, and round-trip
fromCanonicalString(toCanonicalString(x)) == x.
Performance
The hot path (Money::of, Money::plus, Money::minus,
Money::multipliedBy) stays under documented budgets:
| Operation | Budget | Notes |
|---|---|---|
Money::of(string, Currency) |
< 100 µs | includes Decimal construction |
Money::plus(Money) |
< 50 µs | BCMath bcadd at receiver's scale |
Money::multipliedBy(string) |
< 100 µs | includes quantization |
Money::convert(Currency, …) |
< 1 ms | with cached provider, single-hop |
Money::convertWithMetadata(…) |
< 1.2 ms | adds ConversionResult construction |
Money::compare(Money, …) |
< 200 µs | single-currency baseline |
Money::compare(other, provider) |
< 1.2 ms | cross-currency, with provider call |
Money::allocate(int) |
< 1 ms | largest-remainder distribution |
Money::format(string) |
< 500 µs | with \NumberFormatter |
Money::toFloat() |
< 50 µs | one bcdiv cast |
Budgets are tracked via phpbench; regressions fail CI. See
bench/ for the exact scenarios.
Production deployment
- Runtime requirements are the same as
## Requirementsabove. No additional PHP extensions, no native libraries. - Container compilation validates config at build time via
ValidateConfigurationPass. Misconfiguration is caught in CI, not in production. - No floating-point state. Every
amountis a BCMath-precise decimal string. Floats appear only as transient parse targets. - Deterministic rounding. HALF-UP by default; rounding mode is configurable globally. Same input produces the same output across PHP versions and platforms.
- Exchange rates are not bundled. Consumers must implement
ExchangeRateProviderInterfaceand register it via DI. The bundle ships an in-memory provider for tests but never for production. - Logs and metrics. Public exceptions carry
errorCode()for structured logging; no bundle code emits PII or financial values to logs.
Compatibility matrix
| PHP | Symfony | BCMath | Status |
|---|---|---|---|
| 8.3 | 7.x | required | supported |
| 8.4 | 7.x | required | supported |
| 8.4 | 8.x | required | supported |
| 8.5 | 8.x | required | supported |
| 8.2 | any | — | not supported |
Roadmap
Active wave: Wave 0 — Foundation and Contract Baseline (see
docs/ROADMAP.md for the full sequence). The roadmap is reviewed
after each closed implementation task.
Production-readiness status is tracked in
docs/PRODUCTION_READINESS_CHECKLIST.md.
Architecture at a glance
┌────────────────────────────────┐
│ elriseio/finance-money-bundle │
│ (this package, framework- │
│ agnostic core + Symfony │
│ Bundle integration) │
└─────────────┬──────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌───────▼────────┐
│ Money / │ │ Decimal\Math │ │ CurrencyReg. │
│ Currency VO │ │ (BCMath facade) │ │ (ISO + custom)│
└───────┬────────┘ └────────┬────────┘ └───────┬────────┘
│ │ │
└─────────────────────┼─────────────────────┘
│
┌─────────────▼──────────────────┐
│ Symfony DI / Bundle │
│ (services, compiler passes, │
│ config validation) │
└─────────────┬──────────────────┘
│
┌─────────────▼──────────────────┐
│ Sub-packages (Doctrine, Cycle, │
│ Serializer, Forms, Exchange- │
│ Rate providers) — optional, │
│ per consumer │
└────────────────────────────────┘
For component-by-component documentation, see
docs/components/. For public-contract
documentation, see docs/contracts/. For
architectural decision records, see docs/adr/.
Contributing
See CONTRIBUTING.md. Notable requirements:
- PHPStan level 8 over
src/,tests/,bench/. - PHPCS PSR-12 + the
Moneycustom standard. - New public contracts require an ADR before implementation.
- Changes to the arithmetic core require a property-based test covering the new invariant.
- Production-readiness checklist
(
docs/PRODUCTION_READINESS_CHECKLIST.md) must stay green before a release tag. - No references to internal production projects, vendor paths, or private repositories in public docs (sanitization contract).
Security
- No PII handling. The bundle operates on amounts and codes; no customer identifiers, no card data, no account numbers.
- No secrets in code. API keys for rate providers belong in the consumer's secret store (Vault, Symfony secrets, AWS Secrets Manager, …), injected via environment variables.
- BCMath safety limit is set per configuration; exceeding it
raises
MathScaleExceptionrather than silently truncating. - CSPRNG is not used by the bundle (no token generation).
Report security issues privately — see the GitHub Security tab.
Acknowledgements
This bundle consolidates patterns from four production fintech
systems that have run since 2018. The architecture rationale is
documented in docs/architecture.md. The
acknowledgements section is intentionally generic — no internal
project names are exposed in public documentation per the
sanitization contract.
More examples and more docs
Working examples, end-to-end tutorials, provider recipes, and architectural deep-dives live on the project page:
elrise.io/projects/finance-money-bundle
If something is missing on that page, open an issue on
GitHub and tag it docs.
License
MIT — see LICENSE.