Exchange Rate Provider Registry
Contract for registering exchange-rate providers, resolving the configured active provider, and the explicit manual default.
Overview
App\Services\Currency\ExchangeRateProviderRegistry is the composition root for the App\Contracts\Currency\ExchangeRateProvider seam.
Core warehouses every rate relationally in exchange_rates and reads it back through CurrencyService. It ships no automatic feed: rates are entered through the admin UI or the REST API. That default is represented by a real provider — ManualExchangeRateProvider — rather than by absence, so the catalogue always answers "where do rates come from?" and an integration plugin has something to sit alongside.
Providers are quote sources only. They do not convert money, do not write to the database, and are not consulted by CurrencyService. Registering a provider cannot change a stored rate, a snapshot exchange_rate on a financial document, or any calculated total.
Public surface
| Method | Purpose |
|---|---|
register() |
Warehouse one provider class under a new key, optionally attributed to a package |
resolve() |
Resolve one ExchangeRateProvider implementation through the container |
activeKey() |
Return the configured active key |
has() |
Check whether a key is registered |
keys() |
Return every registered key in registration order |
automaticKeys() |
Return the registered keys whose providers fetch rates themselves |
packageFor() |
Return the plugin package that registered a key, or null for core |
Accepted contract
The registered value is a class-string for an App\Contracts\Currency\ExchangeRateProvider implementation, which requires:
key(): string— stable provider key; core keys mirrorApp\Enums\ExchangeRateSourcevalues so a persisted rate traces back to the provider that quoted itlabel(): string— human-readable nameisAutomatic(): bool— whether the provider fetches on its ownfetch(string $baseCurrencyCode, array $targetCurrencyCodes): array— quote the named targets against one base currency
fetch() returns a list of App\ValueObjects\Currency\ExchangeRateQuote objects. A provider with nothing to quote returns an empty list; a provider whose remote source failed throws. Those two outcomes are deliberately distinguishable, so a caller can tell "no rates today" from "the feed is down".
Quote value object
ExchangeRateQuote is immutable and validates itself on construction. It carries sourceCurrencyCode, targetCurrencyCode, a rate decimal string, and an effectiveAt timestamp that defaults to now.
Rates are decimal strings compared and divided with bcmath at eight decimal places — the SCALE constant — matching the decimal:8 columns on exchange_rates and the string rates CurrencyService multiplies through RationalMoney. Floats are never used. inverseRate() derives the reciprocal exactly as CreateExchangeRate does, and toArray() returns an exchange_rates row payload.
Construction throws InvalidArgumentException when either code is not a three-letter ISO 4217 code, when both codes are the same currency, or when the rate is non-numeric or not greater than zero.
Identity, collisions, and construction
Keys are stable registry identities. register() rejects a blank key, a key that is already registered, a class that does not implement ExchangeRateProvider, and a class that is not instantiable. Registration is additive only: nothing can take over the core manual key.
The registry does not require the registration key to equal the provider's own key() return value, and it stores class strings rather than provider objects. resolve() asks Laravel's container on every lookup and does not cache instances, so provider dependencies stay injectable.
The third register() argument records the plugin package that supplied a key; packageFor() reads it back and returns null for core registrations.
Selecting the active provider
resolve() called with no argument uses activeKey(), which reads currency.exchange_rate_provider on every call and falls back to the DEFAULT_KEY constant (manual) when unset.
automaticKeys() resolves each registered provider and keeps the ones whose isAutomatic() returns true. The core catalogue on its own returns an empty list, because the manual provider is not a feed.
Failure behaviour
resolve($key) throws InvalidArgumentException with Unknown exchange rate provider [{key}]. when the key is not registered, including when the configured active key names a provider nothing registered.
register() throws InvalidArgumentException for each rejected registration, naming the blank key, the duplicate key, or the offending class.
Current core registrations
| Key | Provider | Automatic |
|---|---|---|
manual |
ManualExchangeRateProvider |
No |
manual is the only shipped provider and the default value of currency.exchange_rate_provider. Its fetch() returns no quotes and never throws.
Worked example
Quoting rates from the active provider and persisting them through the existing action:
$provider = app(ExchangeRateProviderRegistry::class)->resolve();
foreach ($provider->fetch('GBP', ['EUR', 'USD']) as $quote) {
(new CreateExchangeRate)(CreateExchangeRateData::from([
'source_currency_code' => $quote->sourceCurrencyCode,
'target_currency_code' => $quote->targetCurrencyCode,
'rate' => $quote->rate,
'inverse_rate' => $quote->inverseRate(),
'source' => $provider->key(),
'effective_at' => $quote->effectiveAt,
]));
}
With the core catalogue that loop is a no-op: the manual provider quotes nothing, so nothing is written and rates stay exactly as they were entered.