SIGNALS Documentation
API Reference

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 mirror App\Enums\ExchangeRateSource values so a persisted rate traces back to the provider that quoted it
  • label(): string — human-readable name
  • isAutomatic(): bool — whether the provider fetches on its own
  • fetch(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.