florianv/exchanger
PHP exchange rate provider layer exposing 30+ services (fastFOREX, ECB, national banks) through one ExchangeRateService interface, with chain fallback, PSR-16 caching and historical rates. Maintained since 2016; requires PHP 8.2+.
Exchanger is the provider layer underneath florianv/swap: it gives you 30+ exchange-rate providers, a fallback chain, and historical rates behind one ExchangeRateService interface. It does not ship a Laravel service provider, so a Laravel integration is three steps: install, bind a singleton, resolve it in your own converter service.
1. Install the driver stack. Exchanger needs a PSR-18 client plus a PSR-17 request factory, discovered via php-http/discovery:
composer require florianv/exchanger symfony/http-client nyholm/psr7
2. Put credentials in config, never getenv() in code (Laravel's config:cache breaks raw env() calls outside config files):
// config/exchange.php
return [
'fastforex_key' => env('FASTFOREX_API_KEY'),
'cache_ttl' => (int) env('EXCHANGE_CACHE_TTL', 3600),
];
3. Bind an Exchanger singleton wrapping a Chain so a dead provider degrades instead of 500-ing your checkout:
// app/Providers/ExchangeServiceProvider.php
use Exchanger\Exchanger;
use Exchanger\Service\{Chain, FastForex, EuropeanCentralBank};
public function register(): void
{
$this->app->singleton(Exchanger::class, function () {
$service = new Chain([
new FastForex(null, null, ['api_key' => config('exchange.fastforex_key')]),
new EuropeanCentralBank(), // free, EUR-base only, daily granularity
]);
return new Exchanger($service);
});
}
First use case: convert an order total and record provenance (the rate, its date, and which provider served it) so finance can audit later:
use Exchanger\ExchangeRateQueryBuilder;
$query = (new ExchangeRateQueryBuilder('EUR/USD'))->build();
$rate = app(Exchanger::class)->getExchangeRate($query);
$rate->getValue(); // float, e.g. 1.0823 — never a string
$rate->getDate(); // DateTime of the quote
$rate->getProviderName(); // 'fastforex' or 'european_central_bank'
$totalUsd = round($order->total_eur * $rate->getValue(), 2);
Where to look first: the README quickstart, then doc/readme.md — it holds the per-service configuration reference (each provider's constructor options differ), cache options, and error handling. Read Exchanger\Service\Registry to see the identifiers you can drive from config.
Wrap the package, don't scatter it. Expose one CurrencyConverter class that takes an Exchanger, memoizes rates per request (a chain call is a network round-trip; a loop over 50 orders should hit it once), and returns a small DTO carrying value, date, and provider.
final class CurrencyConverter
{
private array $memo = [];
public function __construct(private \Exchanger\Exchanger $exchanger) {}
public function convert(float $amount, string $from, string $to): ConvertedAmount
{
$pair = "{$from}/{$to}";
$query = (new \Exchanger\ExchangeRateQueryBuilder($pair))->build();
$rate = $this->memo[$pair] ??= $this->exchanger->getExchangeRate($query);
return new ConvertedAmount(
round($amount * $rate->getValue(), 2),
$rate->getValue(),
$rate->getProviderName(),
$rate->getDate(),
);
}
}
Config-driven chains. Make the provider list a config array of [identifier, options] and build the chain in the service provider by resolving each identifier out of Exchanger\Service\Registry. That way staging can run ECB-only at zero cost while production runs fastFOREX → ECB, with no code change.
Cache aggressively — this is the whole point of the package. Rates move on the order of minutes for commercial providers and once a day for central banks. Pass cache options per query (keys like cache, cache_ttl, cache_key — verify exact names in doc/readme.md for your pinned version) and inject Laravel's PSR-16-compatible cache.store. Set TTL by use case: 60–300s for a live pricing widget, 6–24h for historical/back-office conversion, and a stable explicit cache_key when you want the same pair reused across requests.
Historical rates via the same builder. ->setDate($date) (not a separate class — the old Historical service was removed in 2.0). This is the pattern for revaluing past invoices or reconciling payouts at the rate effective on the transaction date.
Use Chain for resilience, but log what it swallows. Unsupported pairs are skipped silently by design; only actual errors propagate, aggregated into a ChainException if every provider fails. Wrap getExchangeRate() in a try/catch and report the ChainException with all nested exceptions — otherwise provider outages are invisible until a customer complains.
Custom providers implement Exchanger\Contract\ExchangeRateService; add the SupportsHistoricalQueries trait if you need setDate() to work. A thin decorator is often better than a new provider: wrap any service to add rate limiting, metrics, request signing, or a database-backed fallback.
Testing. Point the chain at the in-memory PhpArray service in your test suite (it only accepts scalar rates — don't feed it rate objects). Assert on getProviderName() too, so a chain reorder can't silently change behaviour.
composer require florianv/exchanger alone gives you no HTTP client; you need symfony/http-client + nyholm/psr7 (or an explicit client injected into the service constructor). Missing clients surface as a Psr18ClientDiscovery exception at runtime, not at composer time. 2.9.0 swapped HttpClientDiscovery for Psr18ClientDiscovery, so old HTTPlug-era snippets no longer apply — pin ^2.9.getValue() is deliberately a float (changed in 2.0 from string). Multiply, round once at the end, and keep the stored rate alongside the converted amount. If you need exact minor-unit arithmetic, convert via integer cents around the multiplication.EUR/USD works, USD/EUR needs cross-rate math, and a Saturday setDate() may throw or return the prior business day depending on provider. Always have a commercial provider first in the chain for non-EUR pairs.getProviderName() and getDate() with every conversion in anything financial — it's your audit trail.ChainException only when you hit the constrained base. The provider table in the README marks these.env() at runtime. Read keys through config() so php artisan config:cache doesn't silently null out your API key.ExchangeRate is immutable with getCurrencyPair(), getValue(), getDate(), getProviderName(). Don't try to mutate a rate — build a new DTO for anything you want to store or transform.florianv/laravel-swap exists but is a wrapper over Swap, not Exchanger, and tends to lag Laravel releases. For a modern Laravel app, wiring Exchanger directly as shown is usually less risk than adopting the older binding.curs.bnr.ro in 2.9.0) and drop PHP support; an unpinned composer update can hand you a broken provider on a Friday.How can I help you explore Laravel packages today?