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

finance-money-bundle — Money value objects и BCMath для Symfony fintech

→ репозиторий
Symfony-бандл для type-safe monetary value objects с BCMath-арифметикой, реестром валют с enum-разделением Fiat/Crypto/Custom, портом exchange-rate провайдеров через tagged services и hot-path бюджетами под 100 µs для high-load финансовых систем.

Обзор

Finance Money Bundle — Symfony-бандл для high-load финансовых систем, где денежные расчёты идут через ext-bcmath и не допускают float-дрейфа. Бандл даёт иммутабельный Money value object с type-safe арифметикой, реестр валют с enum-разделением Fiat/Crypto/Custom (бандл не поставляет реестр по умолчанию — хост-приложение собирает свой набор через Currency::iso/crypto/custom), порт ExchangeRateProviderInterface для инжекта региональных провайдеров курсов и hot-path бюджеты под 100 µs, валидируемые через phpbench.

Бандл построен по той же модели, что и elriseio/dbal-bundle: framework-agnostic core + Symfony Bundle integration поверх. Core-слой не зависит от Symfony-контейнера и может быть переиспользован в PHP-приложениях без фреймворка; Bundle-слой подгружает services.php и настраивает ExchangeRateProviderPass compiler pass для выбора провайдера курсов из tagged services.

Документ ниже — справочник по самому бандлу: контракты, компоненты, hot-path бюджеты, quality gates. Архитектурная мотивация — почему BCMath-only и type-safe value objects для денег в PHP, а не int cents или float — в отдельной заметке (планируется в Wave 0+).

Быстрый старт

Минимальный сценарий: установить бандл, зарегистрировать, выполнить арифметику через 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

Бандл не поставляет реестр по умолчанию. Хост-приложение собирает реестр на boot (см. §Currency registry) и при необходимости устанавливает его через MoneyRegistry::setDefault($registry) — после этого Money::of('100.00', 'USD') начинает резолвить строковый код через этот реестр.

Полная конфигурация, cross-currency conversion, hot-path бюджеты и quality gates — ниже.

Что это и чем не является

Бандл делает:

Бандл не делает:

Совместимость

Компонент Версия
PHP 8.3, 8.4 или 8.5 (strict types)
ext-bcmath обязательно
(нет) ext-bcmath обязателен; ext-intl не требуется
Composer 2.x
Symfony 7.x или 8.x (для Bundle-слоя; core — framework-agnostic)

PHP 8.2 не поддерживается. CI прогоняет матрицу PHP 8.3 / 8.4 / 8.5 × Symfony 7.x / 8.x.

Архитектура

Core-слой framework-agnostic: Money, Currency, Decimal\Math, CurrencyRegistry и контракты ExchangeRateProviderInterface / CurrencyRegistryInterface живут без зависимости на Symfony. Bundle-слой — отдельный Symfony\Component\DependencyInjection-конфиг + compiler passes + attribute-регистрация.

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

Контракты

Symfony Bundle

Установка

composer require elriseio/finance-money-bundle

Регистрация бандла в config/bundles.php:

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

Конфигурация у бандла в текущей версии отсутствует как Configuration class — FinanceMoneyExtension::load() подгружает только services.php. Управление провайдерами курсов делается через tagged services и compiler pass, а не через elrise_finance_money.* секцию.

DI-wiring для собственного провайдера курсов в services.yaml (override дефолтного InMemoryExchangeRateProvider через priority):

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

ExchangeRateProviderPass выберет кандидата с наивысшим priority при компиляции контейнера; InMemoryExchangeRateProvider остаётся запасным вариантом, если ни один собственный провайдер не зарегистрирован (priority 0).

#[AsCurrency] attribute registration (planned, not in current code)

USAGE.md упоминает #[AsCurrency] attribute как способ discovery кастомных валют:

// Ожидаемый (после публикации атрибута) синтаксис:
// #[AsCurrency(priority: 50)]
// final class LoyaltyPointsCurrency implements CurrencyInterface { ... }

В текущей версии src/Attribute/AsCurrency.php не существует, и compiler pass для discovery CurrencyInterface-имплементаций не зарегистрирован. Хост, которому нужна аналогичная функциональность, реализует её на своей стороне через:

# services.yaml — обычный tagged-service enumeration
services:
    App\Currency\LoyaltyPointsCurrency:
        tags: ['app.currency.custom']

# + boot listener (KernelEvents::REQUEST), который обходит tagged services
# и вызывает CurrencyRegistry::mutable()->register() / fromCatalogue() с распарсенными валютами.

Если атрибут будет опубликован — этот раздел обновится в той же сессии.

Money value object

Money — иммутабельный final readonly class. Каждая арифметическая операция возвращает новый экземпляр. Сумма хранится как BCMath-precise decimal string; bcdiv / bcmod / bcscale за пределами Decimal\Math заблокированы кастомным PHPStan-правилом.

Создание

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

$btc = Currency::crypto('BTC', 8);
$usd = Currency::iso('USD', '840');

$money = Money::of('100.00', $btc);
$money = Money::of('100.00', 'USD');             // строкой, если Money::setDefaultRegistry() вызван
$money = Money::fromMinor(1999, $usd);           // из minor units (int)
$money = Money::zero($btc);                      // нулевое значение
$money = Money::tryFromAny($input, $btc);        // best-effort, возвращает ?Money

Фабрики Money::zero($currency), Money::fromMinor(int $minorUnits, Currency $currency), Money::fromCanonicalString(string $value, Currency $currency) и Money::tryFromAny(mixed $input, Currency $currency) закрывают типовые сценарии инициализации из разных источников.

Арифметика

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

$balance = $deposit->minus($fee);                  // 97.50 USD
$total   = $deposit->plus($fee)->plus($fee);       // 105.00 USD

Умножение и деление

$subtotal = Money::of('100.00', $usd);
$discount = $subtotal->multipliedBy('0.85');       // 85.00 USD
$perItem  = $subtotal->dividedBy(4);               // 25.00 USD

Множители и делители трактуются как value scalars на scale получателя (src/Money.php:181-202), так что Money('100.00', USD)->dividedBy('4') — это 25.00 USD, не 2500.0000 USD. Это закрывает классический float-style scale drift.

Модуло, отрицание, абсолют

$amount = Money::of('100.00', $usd);
$amount->mod(Money::of('7.00', $usd));             // 2.00 USD
$amount->negated();                                // -100.00 USD
$amount->abs();                                    //  100.00 USD

Равенство

$a = Money::of('100.00', $usd);
$b = Money::of('100',    $usd);     // scale-нормализация к USD scale
$c = Money::of('100.00', $eur);

$a->equals($b);                     // true (одна валюта, квантизация совпадает)
$a->equals($c);                     // false (разные валюты)

Все арифметические методы возвращают новый экземпляр. Money::allocate(int $parts) распределяет сумму методом наибольшего остатка — сумма частей равна исходной с точностью до scale валюты.

Cross-currency операции

Cross-currency plus / minus / multipliedBy / equals — это исключение (CurrencyMismatchException) до того, как BCMath-код запустится. Это явно: в финансовом коде молчаливое plus USD к EUR — не баг-сюрприз, который надо отлаживать ночью.

Cross-currency convert() и compare() — opt-in с явным провайдером (см. §Cross-currency conversion):

$sign = $usdMoney->compare($eurMoney, $rateProvider);
// -1 / 0 / 1 — после конверсии в одну валюту через провайдер

Инспекция

$money->amount();            // Decimal (canonical)
$money->canonicalString();   // "100.00"
$money->minorUnits();        // int (canonical × 10^scale)
$money->currency();          // Currency
$money->isZero();            // bool
$money->isPositive();        // bool
$money->isNegative();        // bool

Форматирование

format() возвращает canonical value + пробел + ISO-4217 alpha-3 code. Бандл не использует \NumberFormatter и не зависит от ext-intl — параметр $locale объявлен для будущего расширения, но сейчас unset($locale):

$total = Money::of('120.50', $usd);
echo $total->format();                // "120.50 USD"

Символы валют ($, €, ₽) считаются presentation metadata на конкретном Currency классе, и format() их не использует. Для рендеринга с локализованным символом есть beautify():

$display = $total->beautify(
    $total->canonicalString(),  // string — pre-formatted value
    2,                         // int — display precision (declared per spec, не используется)
    'US$',                     // string — display symbol (дословно)
);
// "US$ 120.50"

beautify() намеренно дословно склеивает: возвращает "$symbol $value". Бандл не ищет символы внутри — хост отвечает за per-locale symbol catalogue на UI edge.

Money::format() и Money::beautify() НЕ покрываются MoneyInterface — это конкретные методы на Money. Реализация может измениться без поломки контракта.

JSON

Money имплементирует JsonSerializable. Дефолтная форма — {amount, currency}:

echo json_encode([
    'price' => Money::of('120.50', $usd),
    'total' => Money::of('241.00', $usd),
]);
// {"price":{"amount":"120.50","currency":"USD"},"total":{"amount":"241.00","currency":"USD"}}

Никаких symbol, scale или presentation metadata в JSON не уходит — это Money-уровень, не сериализатор. Symfony Serializer Normalizer / Denormalizer делается хост-приложением поверх jsonSerialize() / tryFromAny().

Реестр валют

CurrencyRegistryInterface — реестр с двумя режимами сборки: CurrencyRegistry::fromCatalogue(array $catalogue, int $catalogueVersion) для immutable реестра (rejects register с ImmutableRegistryException), и CurrencyRegistry::mutable(array $seed = [], int $catalogueVersion = 0) для mutable реестра, принимающего register(...).

Бандл не поставляет реестр по умолчанию. Хост-приложение собирает его на 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

Для immutable реестра со скомпилированным каталогом:

$catalog = CurrencyRegistry::fromCatalogue(
    catalogue: [...],                 // list<Currency>
    catalogueVersion: 42,
);
$catalog->isMutable();                // false — register() бросает исключение
$catalog->catalogueVersion();         // 42

Поиск

$usd    = $registry->get('USD');                 // бросает 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

Три статические фабрики закрывают основные сценарии построения. symbol и nameпредоставляемые потребителем presentation metadata; бандл никогда не использует их сам, хост-приложение передаёт свои per-locale символы на UI edge.

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

// Опциональные symbol и name (по умолчанию = code):
Currency::iso('USD', '840', '$', 'US Dollar');
Currency::crypto('BTC', 8, '₿', 'Bitcoin');

Семантические инварианты:

Все коды uppercase'ятся и проверяются на ASCII-alphanumeric на construction (src/Currency/Currency.php:159-174).

Установка реестра по умолчанию

Money::of(string, $currency) принимает либо Currency instance, либо строковый код. Строковый путь резолвится через process-wide реестр по умолчанию, который ставится через:

use Elrise\Finance\Bundle\Money\MoneyRegistry;

MoneyRegistry::setDefault($registry);

// или эквивалентный шорткат:
Money::setDefaultRegistry($registry);

// Теперь Money::of('100.00', 'USD') резолвит 'USD' через $registry.

В Symfony-приложении реестр по умолчанию ставится вручную через MoneyRegistry::setDefault($registry) на KernelEvents::REQUEST listener'е (или аналогичном boot hook'е) — атрибут #[AsCurrency], упомянутый в USAGE.md, не опубликован в текущей версии (см. §Symfony Bundle wiring).

type ∈ {Fiat, Crypto, Custom} — enum-разделение, доступное на уровне типа (src/Currency/CurrencyType.php). Реестр хранит состояние в hash-map и потокобезопасен для long-running workers (RoadRunner, Swoole, FrankenPHP) без дополнительной синхронизации.

Decimal engine и BCMath-границы

Все арифметические операции идут через Decimal\Math (src/Decimal/Math.php) — тонкий BCMath-фасад. Методы add, sub, mul, div, cmp, quantize принимают canonical numeric strings и int scale, никогда не возвращают PHP float.

Если ext-bcmath недоступен, Math::assertBcMath() бросает BcmMathUnavailableException на первом вызове. Проверка кэшируется в self::$bcmathAvailable — после первого failure последующие вызовы не валят на BC availability check повторно.

Режимы округления (RoundingMode)

src/Decimal/RoundingMode.php — enum с семью кейсами, которые покрывают типовые банковские и инженерные сценарии:

Case Семантика Пример (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 — режим по умолчанию в арифметике Money::* (multipliedBy / dividedBy / convert всегда quantize через HALF_UP).

ForbiddenBcFunctionInDecimalRule

Прямой вызов bcdiv, bcmod, bcscale за пределами Decimal\Math заблокирован custom PHPStan-правилом 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() бросает RuleError при попытке прямого вызова
}

bcadd, bcsub, bcmul, bccomp не входят в forbidden set — они уже маршрутизируются через Math и держат область действия правила узкой на три функции. Сама фасадная Decimal\Math разрешена через белый список имён классов.

Это не "PHP 8.4 не поддерживает bcdiv" — это архитектурное решение: вся BCMath-семантика живёт в Decimal\Math, любая попытка вызвать BCMath напрямую из кода в namespace Elrise\Finance\Bundle\Money\Decimal\ — code-smell и блокируется на уровне анализатора.

Float в hot path

Custom PHPCS-sniff'ы (tests/Rules/Money/Sniffs/HotPath/) защищают от float в hot-path файлах:

Float не принимается как Money::of() аргумент (сигнатура string|int); Money::of(100.50, $usd) это TypeError по дизайну. Хост должен конвертировать на границе через number_format():

$money = Money::of(number_format((float) $userInput, 2, '.', ''), $usd);

Это закрывает класс багов "передали float, он дрифтанул на арифметике".

Cross-currency conversion

Cross-currency конверсия идёт через порт ExchangeRateProviderInterface. Бандл экспонирует два метода:

ConversionResultfinal readonly DTO с публичными геттерами from(), to(), rate(): string, source(): ?string, at(): ?DateTimeImmutable. source и at пробрасываются из 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' (от провайдера; null для InMemoryExchangeRateProvider)
// $result->at()      === DateTimeImmutable(...)

Аудит конверсии — то, что финансовый регулятор хочет видеть в первую очередь: кто дал курс, в какой момент. source = null — допустимое состояние для провайдеров, которые не имеют собственного логического имени (например, InMemoryExchangeRateProvider).

MoneyConverter service

MoneyConverter (src/Service/MoneyConverter.php) — fluent-обёртка вокруг (provider + registry + optional clock), которая избавляет от повторного инжекта провайдера в каждом call site. Сервис не меняет контракт Money и не вводит собственный кэш — кэширование остаётся на стороне провайдера.

Конструктор

use Elrise\Finance\Bundle\Money\Service\MoneyConverter;

public function __construct(
    private MoneyConverter $converter,    // autowired через DI alias
) {}

Под капотом:

public function __construct(
    private ExchangeRateProviderInterface $provider,
    private CurrencyRegistryInterface $registry,
    private ?\DateTimeImmutable $now = null,
) {}

ExchangeRateProviderInterface autowire'ится через alias, который ExchangeRateProviderPass резолвит на highest-priority tagged provider.

Конвертация

$eur = $this->converter->convert(
    money:  Money::of('100.00', $usd),
    target: 'EUR',                       // строка через registry
    at:     $clockAt,                    // опционально, перекрывает injected clock
);

target принимает Currency|string; строковый путь резолвится через registry->get($target) ровно один раз. $at опционален и при null использует injected clock ($now), иначе — new DateTimeImmutable().

Только курс

$rate = $this->converter->rate($usd, 'EUR');
// Decimal — бросает ExchangeRateUnavailableException на miss

Bulk: basket reporting и сводка

$basket = [
    Money::of('100.00', $usd),
    Money::of('50.00',  $usd),
    Money::of('25.00',  $usd),
];

$inEur   = $this->converter->convertAll($basket, 'EUR');  // list<Money> в EUR
$totalEu = $this->converter->sum($basket, 'EUR');         // один Money в EUR

sum() бросает \InvalidArgumentException на пустой iterable — у zero-element basket нет определённой целевой суммы (это input validation, а не domain rule).

Override провайдера mid-request (тесты)

$scoped = $this->converter->with($stubProvider);
// новый MoneyConverter, registry и clock сохранены

Реализация кастомного провайдера

namespace App\ExchangeRate;

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

final readonly class EcbRateProvider implements ExchangeRateProviderInterface
{
    public function __construct(
        private \Symfony\Contracts\HttpClient\HttpClientInterface $http,
        private CurrencyRegistryInterface $registry,
    ) {}

    public function getRate(Currency $from, Currency $to, DateTimeImmutable $at): ?Decimal
    {
        // ...fetch rate; return null on miss/network failure...
    }

    public function rateWithMetadata(Currency $from, Currency $to, DateTimeImmutable $at): ConversionResult
    {
        return new ConversionResult(
            from:   Money::zero($from),
            to:     Money::zero($to),
            rate:   $this->getRate($from, $to, $at)?->canonicalString() ?? '0',
            source: 'ecb',
            at:     $at,
        );
    }
}

Регистрация через tagged service в services.yaml:

services:
    app.exchange_rate.ecb_provider:
        class: App\ExchangeRate\EcbRateProvider
        arguments:
            - '@http_client'
            - '@Elrise\Finance\Bundle\Money\Contract\CurrencyRegistryInterface'
        tags:
            - { name: 'finance_money.exchange_rate_provider', priority: 100 }

priority tag определяет, кто побеждает на compile time при наличии нескольких провайдеров. Чем выше число — тем выше приоритет.

Для тестов бандл поставляет InMemoryExchangeRateProvider:

use Elrise\Finance\Bundle\Money\ExchangeRate\InMemoryExchangeRateProvider;

$provider = new InMemoryExchangeRateProvider([
    'EUR' => ['USD' => '1.0850'],
    'USD' => ['EUR' => '0.9217'],
]);

Exchange rate providers

ExchangeRateProviderInterface — порт для провайдеров курсов. Реальная сигнатура (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() возвращает Decimal|null — мультипликативный курс r, квантизированный к RATE_SCALE, или null если пара недоступна. Money::convert() транслирует null в ExchangeRateUnavailableException.

Бандл регистрирует InMemoryExchangeRateProvider как провайдер по умолчанию через 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 принимает пустой rates map по умолчанию и используется как резервный вариант: ExchangeRateProviderPass compiler pass выбирает кандидата с наивысшим priority из всех tagged services. Хост-приложение регистрирует собственный провайдер через tags: [{ name: 'finance_money.exchange_rate_provider', priority: 100 }], и он перекрывает вариант по умолчанию.

Конкретные региональные провайдеры (CbrRateProvider, EcbRateProvider, BinanceRateProvider, ...) делаются хост-приложением — отдельного companion-пакета в текущей версии нет. Реализации должны быть иммутабельны и не иметь скрытого I/O на monetary hot path (кэширование, агрегация и freshness-проверки — за пределами контракта).

Hot-path бюджеты

Бенчмаркинг устроен двухслойно — bench/ и itests/ — и оба НЕ заменяют таблицу бюджетов из README, потому что в bench/ лежит только DecimalBench::benchPlusScaleEight() (bench/DecimalBench.php), измеряющий throughput Decimal::plus на scale 8. Это рекомендательный harness — не принудительный gate: composer bench opt-in, в CI smoke (itests-smoke.yml) не входит. Throughput gate — запланирован на Wave 4 и в текущей версии ещё не активирован.

Реальное состояние hot-path покрытия:

Таблица бюджетов из README (Money::of < 100 µs, Money::plus < 50 µs, Money::multipliedBy < 100 µs и т.п.) — это target spec, а не измеренные цифры. До Wave 4 эти цифры не подтверждены бенчмарками и не принудительно проверяются в CI; регрессии в throughput на текущей версии пройдут незамеченными. Хосту, которому важны per-call задержки, рекомендуется запустить composer bench на своём окружении и замерить свой реальный hot path.

Quality gates и тестирование

Полный набор quality gates:

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 микро-бенчмарки
composer guard         # stan + cs + property (полный quality gate)
composer itests:envelope # itests envelope contract

Все четыре команды exit non-zero на failure. CI прогоняет матрицу на PHP 8.3, 8.4, 8.5 против Symfony 7.x и 8.x.

Кастомные правила

Тестовые suite

Suite Расположение Назначение
Unit tests/Unit/ Чистые unit-тесты, без Symfony-контейнера
Contract tests/Contract/ Public-interface инварианты; cross-implementation consistency
Property-based tests/Property/ Round-trip и invariant fuzzing через Eris
Integration itests/ End-to-end с реальным Symfony-контейнером, in-memory rate provider, scenario runners
Benchmark bench/ phpbench микро-бенчмарки для hot-path бюджетов
Failure-mode itests/FailureMode/ Cache-down, provider-down, BCMath-scale-overflow

Property-based тесты проверяют алгебраические инварианты:

Потрогать руками

dummy-market-agent — reference Symfony-сервис, в котором elriseio/finance-money-bundle, elriseio/application-layer-bundle, elriseio/dbal-bundle и API Platform собраны в одном запускаемом приложении. В нём доступны DI-wiring, hot-path сценарии, exchange-rate port с тестовым провайдером и интеграция с persistence через собственные Doctrine Type / Serializer Normalizer.

Репозиторий запускается через Docker; команды прогонов и сценарии описаны в README репо.

Известные ограничения

Источники