user@elrise.io:~
· [live]· 1.0.0tagsphpsymfonybcmathmoneycurrencyvalue-objectbundlefintech

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.

→ repository

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 Money and Currency as final readonly value 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\Math facade; direct bcdiv / bcmod / bcscale outside Decimal\ is blocked by the custom PHPStan rule ForbiddenBcFunctionInDecimalRule;
  • match currencies at the type level: cross-currency plus / minus is a compile-time error (CurrencyMismatchException); cross-currency comparison via Money::compare(other, ?provider) is opt-in with an explicit provider;
  • ship CurrencyRegistryInterface with has / get / tryGet / all / byType / register / catalogueVersion / isMutable; currency type is the CurrencyType enum with three cases Fiat / Crypto / Custom. The bundle ships no default registry — the host application assembles it at boot via CurrencyRegistry::mutable([...]) or CurrencyRegistry::fromCatalogue(...) (see §Currency registry);
  • expose ExchangeRateProviderInterface via DI as a tagged service finance_money.exchange_rate_provider; the ExchangeRateProviderPass compiler pass picks the highest-priority candidate. InMemoryExchangeRateProvider is registered as the default (priority 0); concrete providers (CbrRateProvider, EcbRateProvider, BinanceRateProvider, etc.) are not in core — the host application implements them on top of ExchangeRateProviderInterface (see §MoneyConverter service → Custom provider implementation);
  • return a ConversionResult DTO with public from() / to() / rate() / source() / at() for conversion audit; opt-in via Money::convertWithMetadata(); the simpler Money::convert() remains for the basic use case;
  • register a Symfony Bundle that loads services.php and configures the ExchangeRateProviderPass compiler pass. The bundle does not ship a Configuration class — provider configuration is via tagged services, not via an elrise_finance_money.* section;
  • validate hot-path budgets via phpbench (see §Hot-path budgets); regressions fail CI;
  • emit structured exceptions through a single DomainException (parent of CurrencyMismatchException, UnknownCurrencyException, ExchangeRateUnavailableException, DivisionByZeroException, InvalidAmountException, MathScaleException, ImmutableRegistryException, FundConversionOverflowException, InvalidExternalMultiplierException, BcmMathUnavailableException); integration with PSR-3 loggers via ThrowableRenderer.

The bundle does not:

  • does not provide a Doctrine Type for Money — the host application implements one on its own side via Type::convertToPHPValue + convertToDatabaseValue, using Money::canonicalString() / fromCanonicalString() (or minorUnits() for integer columns);
  • ship exchange-rate providers in core — only the ExchangeRateProviderInterface contract. 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 returns canonical + space + ISO-4217 code) covers — for locale-aware rendering, pass the symbol through Money::beautify(value, precision, symbol) from your per-locale catalogue;
  • support PHP 8.2 — the bundle uses final readonly class, enum cases, and typed constants;
  • promise zero-cost abstraction — every Money::plus goes through bcadd at 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

ComponentVersion
PHP8.3, 8.4, or 8.5 (strict types)
ext-bcmathrequired
Composer2.x
Symfony7.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 factories Money::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): string returns "<symbol> <value>" verbatim.
  • Currency (final readonly) — (code, scale, type, symbol, name); type ∈ {Fiat, Crypto, Custom}.
  • Decimal\Math — BCMath facade; direct bcdiv / bcmod / bcscale outside Decimal\ is blocked by ForbiddenBcFunctionInDecimalRule.
  • CurrencyRegistryInterfaceget(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 via Money::convertWithMetadata().

Symfony Bundle

  • Config key elrise_finance_money. Supports the default_currency, default_multiplier, bcmath_scale, view_precision, rounding_mode, currencies, custom_currencies, formatter_locale, and exchange_rate_provider sections.
  • #[AsCurrency] attribute mentioned in USAGE.md as a way to discover custom currencies, but not yet published in the current src/ tree (src/Attribute/ is empty); see §Symfony Bundle wiring for current-state workaround.
  • ExchangeRateProviderPass compiler pass — selects the ExchangeRateProviderInterface among tagged services with the highest priority at container compile time; default is InMemoryExchangeRateProvider at priority 0.
  • Auto-wired services: MoneyConverter via 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 case Custom (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:

CaseSemanticsExample (2.5)
HALF_UPround half towards positive infinity (IEEE 754 “round half up”)3
HALF_EVENbanker’s rounding, round half to even2
HALF_DOWNround half towards zero2
DOWNtruncate toward zero2.9 → 2
UPround away from zero2.1 → 3
CEILINGround toward positive infinity-2.9 → -2
FLOORround 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 on float in method signatures, return types, or property declarations in hot-path files.
  • Money.HotPath.ImmutabilityGuard — fails if a value object in hot-path files is missing final or readonly, declares a non-readonly property, or exposes a public setter.

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:

  • Money::convert(Currency $target, ExchangeRateProviderInterface $provider, ?\DateTimeImmutable $at = null): self — basic, returns the converted Money. Throws CurrencyMismatchException when $this->currency and $target are the same currency; ExchangeRateUnavailableException when the provider returns null for the pair.
  • Money::convertWithMetadata(Currency $target, ExchangeRateProviderInterface $provider, ?\DateTimeImmutable $at = null): ConversionResult — opt-in with metadata audit. Wraps convert() and additionally returns a ConversionResult with rate, source, and at.

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 for Decimal\Decimal. Measures per-call cost on hand-crafted shapes (Decimal::of('12345678.12345678', 8)->plus(...) × 3). Does not measure Money::* 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 runs itests/, not bench/.

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 on float in signature/return-type/property declaration in src/Money.php, src/Currency/, src/Decimal/.
  • Money.HotPath.ImmutabilityGuard (PHPCS) — fails if a value object in hot-path files is missing final or readonly, declares a non-readonly property, or exposes a public setter.
  • ForbiddenBcFunctionInDecimalRule (PHPStan) — blocks direct bcdiv / bcmod / bcscale in Decimal\ outside Decimal\Math.

Test suites

SuiteLocationPurpose
Unittests/Unit/Pure unit tests, no Symfony container
Contracttests/Contract/Public-interface invariants; cross-implementation consistency
Property-basedtests/Property/Round-trip and invariant fuzzing via Eris
Integrationitests/End-to-end with real Symfony container, in-memory rate provider, scenario runners
Benchmarkbench/phpbench micro-benchmarks for hot-path budgets
Failure-modeitests/FailureMode/Cache-down, provider-down, BCMath-scale-overflow

Property-based tests assert algebraic invariants:

  • add(a, b) == add(b, a)
  • multiply(a, 1) == a
  • allocate(n) sums to original
  • fromCanonicalString(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::plus goes through bcadd (~30-50 µs). If your hot path runs tens of thousands of operations per second per worker, use native int cents / int satoshi and convert to Money only at layer boundaries. The bundle is not designed for that.
  • PHP 8.2 is not supported. The bundle uses final readonly class, enum cases, and typed constants. The minimum is 8.3.
  • Concrete exchange-rate providers are not in core. Implementations of CbrRateProvider, EcbRateProvider, BinanceRateProvider are built by the host application on top of ExchangeRateProviderInterface (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 calls Money::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 Money signatures, migration requires explicit Money::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

Discussion

Comments (0)

No comments yet.