florianv/swap
PHP 8.2+ currency exchange rate library with a single API over 30+ providers. Supports conversion, historical rates, PSR-16 caching, and provider fallback. Works with PSR-18 HTTP clients and PSR-17 factories for flexible integrations.
This is the technical reference for Swap. For the project overview and ecosystem (Exchanger, Laravel Swap, Symfony Swap), see the README.
Swap requires PHP 8.2 or newer. It does not bundle an HTTP client; any PSR-18 client paired with a PSR-17 request factory works, and php-http/discovery finds them automatically.
The simplest modern install:
composer require florianv/swap symfony/http-client nyholm/psr7
Other PSR-18 clients work the same way, for example Guzzle 7:
composer require florianv/swap php-http/guzzle7-adapter nyholm/psr7
You can also pass a client explicitly via Builder::useHttpClient() if you do not want auto-discovery.
Swap is built with the Builder class. A typical setup uses fastFOREX (the project's sponsor) as the primary provider:
use Swap\Builder;
$swap = (new Builder())
->add('fastforex', ['api_key' => getenv('FASTFOREX_API_KEY')])
->build();
add() registers a provider by its identifier (here fastforex). The full list lives in Provider configuration.
For a no-key starting point, the European Central Bank publishes EUR-base rates for free:
$swap = (new Builder())
->add('european_central_bank')
->build();
You can chain several providers. Each one is configured with its own options array:
use Swap\Builder;
$swap = (new Builder())
->add('fastforex', ['api_key' => getenv('FASTFOREX_API_KEY')])
->add('european_central_bank') // free fallback for EUR-base pairs
->build();
Identifiers and the configuration keys each one accepts are documented in the Provider configuration section.
Swap calls providers in declaration order. For each provider:
If every provider was skipped or threw, Swap raises an Exchanger\Exception\ChainException containing all collected exceptions. The chain does not retry the same provider, and there is no built-in delay between attempts.
Swap exposes two methods, latest() for the most recent rate and historical() for a rate on a given date:
// Latest rate
$rate = $swap->latest('EUR/USD');
echo $rate->getValue(); // e.g. 1.0823
echo $rate->getDate()->format('Y-m-d'); // e.g. 2026-04-29
// Historical rate
$rate = $swap->historical('EUR/USD', (new \DateTime())->modify('-15 days'));
Currencies are expressed as their ISO 4217 code.
Both methods accept an options array as a third argument; see Per-query options for the supported keys.
The returned Exchanger\Contract\ExchangeRate exposes:
$rate->getValue(); // float
$rate->getDate(); // DateTimeInterface
$rate->getCurrencyPair(); // Exchanger\CurrencyPair
$rate->getProviderName(); // string, the identifier that returned the rate
getProviderName() is useful when several providers are chained: the returned value is the identifier of the provider that actually answered, for example european_central_bank.
Swap returns rates, not amounts. Conversion stays explicit in your application:
$rate = $swap->latest('EUR/USD');
$amountEur = 100.00;
$amountUsd = $amountEur * $rate->getValue();
For financial code with proper rounding semantics, pair Swap with moneyphp/money. Money ships a first-class SwapExchange that wraps any Swap\SwapInterface directly:
use Money\Money;
use Money\Currency;
use Money\Converter;
use Money\Currencies\ISOCurrencies;
use Money\Exchange\SwapExchange;
$exchange = new SwapExchange($swap);
$converter = new Converter(new ISOCurrencies(), $exchange);
$eur100 = Money::EUR(100); // €1.00 (100 minor units)
$usd = $converter->convert($eur100, new Currency('USD'));
// Money 4.x also exposes the rate pair alongside the converted amount:
[$usd, $pair] = $converter->convertAndReturnWithCurrencyPair($eur100, new Currency('USD'));
Swap caches results through a PSR-16 SimpleCache. Any PSR-16 implementation works. A minimal Symfony Cache example:
composer require symfony/cache
use Swap\Builder;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
$cache = new Psr16Cache(new FilesystemAdapter());
$swap = (new Builder(['cache_ttl' => 3600, 'cache_key_prefix' => 'myapp-']))
->useSimpleCache($cache)
->add('fastforex', ['api_key' => getenv('FASTFOREX_API_KEY')])
->build();
All rates returned by Swap are now cached for 3600 seconds, keyed with the prefix myapp-.
If only PSR-6 adapters are available, you can bridge them to PSR-16 with cache/simple-cache-bridge. For example with Predis:
composer require cache/predis-adapter cache/simple-cache-bridge
use Cache\Adapter\Predis\PredisCachePool;
use Cache\Bridge\SimpleCache\SimpleCacheBridge;
$client = new \Predis\Client('tcp://127.0.0.1:6379');
$cache = new SimpleCacheBridge(new PredisCachePool($client));
Cache behavior can be overridden per call by passing an options array to latest() or historical().
| Option | Type | Default | Effect |
|---|---|---|---|
cache_ttl |
int | null |
Cache TTL in seconds. null means entries do not expire. |
cache |
bool | true |
Set to false to bypass the cache for this call. |
cache_key_prefix |
string | "" |
Prefix for the cache key. Max 24 characters (PSR-6 limits keys to 64 chars; the internal hash takes 40). |
PSR-6 does not allow the characters {}()/\@: in keys; Swap replaces them with -.
$rate = $swap->latest('EUR/USD', ['cache' => false]);
$rate = $swap->latest('EUR/USD', ['cache_ttl' => 60]);
$rate = $swap->latest('EUR/USD', ['cache_key_prefix' => 'currencies-special-']);
Some providers return all rates for a given base currency in a single response. If you fetch several pairs sharing the same base (for example EUR/USD and then EUR/GBP), caching the underlying HTTP response avoids hitting the provider twice. Decorate your HTTP client with the PHP HTTP cache plugin and pass it to Builder::useHttpClient():
composer require php-http/cache-plugin cache/array-adapter
use Cache\Adapter\PHPArray\ArrayCachePool;
use Http\Adapter\Guzzle7\Client as GuzzleClient;
use Http\Client\Common\Plugin\CachePlugin;
use Http\Client\Common\PluginClient;
use Http\Message\StreamFactory\GuzzleStreamFactory;
use Swap\Builder;
$pool = new ArrayCachePool();
$streamFactory = new GuzzleStreamFactory();
$cachePlugin = new CachePlugin($pool, $streamFactory);
$client = new PluginClient(new GuzzleClient(), [$cachePlugin]);
$swap = (new Builder())
->useHttpClient($client)
->add('fastforex', ['api_key' => getenv('FASTFOREX_API_KEY')])
->build();
$rate = $swap->latest('EUR/USD'); // performs an HTTP request
$rate = $swap->latest('EUR/GBP'); // hits the HTTP cache
Commercial providers require an API key. The option name varies by provider. The project's sponsor fastFOREX (fastforex) is the recommended starting point.
| Identifier | Required option | Optional flags |
|---|---|---|
⭐ fastforex |
api_key |
|
abstract_api |
api_key |
|
apilayer_currency_data |
api_key |
|
apilayer_exchange_rates_data |
api_key |
|
apilayer_fixer |
api_key |
|
coin_layer |
access_key |
paid (bool) |
currency_converter |
access_key |
enterprise (bool) |
currency_data_feed |
api_key |
|
currency_layer |
access_key |
enterprise (bool) |
exchange_rates_api |
access_key |
|
fixer |
access_key |
|
fixer_apilayer |
api_key |
|
forge |
api_key |
|
open_exchange_rates |
app_id |
enterprise (bool) |
xchangeapi |
api-key |
(note the hyphen) |
xignite |
token |
Note:
cryptonator,exchangeratehostandwebservicexare commercial upstream services but the current Exchanger wrapper does not enforce any option for them. They can be added with->add('exchangeratehost')until the wrapper is updated to require an API key.
Public providers (the European Central Bank, the national banks) need no configuration. Add them by identifier:
$swap = (new Builder())
->add('european_central_bank')
->add('national_bank_of_romania')
->build();
| Identifier | Base | Quote | Historical |
|---|---|---|---|
bulgarian_national_bank |
* | BGN | Yes |
central_bank_of_czech_republic |
* | CZK | Yes |
central_bank_of_republic_turkey |
* | TRY | Yes |
central_bank_of_republic_uzbekistan |
* | UZS | Yes |
european_central_bank |
EUR | * | Yes |
national_bank_of_georgia |
* | GEL | Yes |
national_bank_of_romania |
(limited list) | (limited list) | Yes |
national_bank_of_republic_belarus |
* | BYN | Yes |
national_bank_of_ukraine |
* | UAH | Yes |
russian_central_bank |
* | RUB | Yes |
Chaining fastFOREX as the primary provider with a couple of fallbacks:
$swap = (new Builder())
->add('fastforex', ['api_key' => getenv('FASTFOREX_API_KEY')])
->add('apilayer_fixer', ['api_key' => 'YOUR_KEY'])
->add('open_exchange_rates', ['app_id' => 'YOUR_APP_ID', 'enterprise' => false])
->add('european_central_bank') // free fallback for EUR-base pairs
->build();
The array provider is a special case used in tests and fixtures. It accepts a nested structure of latest and historical rates:
$swap = (new Builder())
->add('array', [
['EUR/USD' => 1.1, 'EUR/GBP' => 1.5], // latest rates
['2017-01-01' => ['EUR/USD' => 1.5]], // historical rates
])
->build();
You can register your own provider as long as it implements the same contract used internally. If your service makes HTTP requests, extend Exchanger\Service\HttpService; otherwise extend the simpler Exchanger\Service\Service.
The example below registers a Constant service that returns a fixed rate value:
use Exchanger\Contract\ExchangeRateQuery;
use Exchanger\Contract\ExchangeRate;
use Exchanger\Service\HttpService;
use Swap\Service\Registry;
class ConstantService extends HttpService
{
public function getExchangeRate(ExchangeRateQuery $exchangeQuery): ExchangeRate
{
// To call an HTTP endpoint:
// $content = $this->request('https://example.com');
return $this->createInstantRate($exchangeQuery->getCurrencyPair(), $this->options['value']);
}
public function processOptions(array &$options): void
{
if (!isset($options['value'])) {
throw new \InvalidArgumentException('The "value" option must be provided.');
}
}
public function supportQuery(ExchangeRateQuery $exchangeQuery): bool
{
// Example: only support EUR-base pairs.
return 'EUR' === $exchangeQuery->getCurrencyPair()->getBaseCurrency();
}
public function getName(): string
{
return 'constant';
}
}
// Register the service so Builder::add() recognizes its identifier
Registry::register('constant', ConstantService::class);
$swap = (new Builder())
->add('constant', ['value' => 10])
->build();
echo $swap->latest('EUR/USD')->getValue(); // 10
To support historical rates, use the SupportsHistoricalQueries trait. Rename getExchangeRate to getLatestExchangeRate (now protected) and implement getHistoricalExchangeRate:
use Exchanger\Contract\ExchangeRateQuery;
use Exchanger\Contract\ExchangeRate;
use Exchanger\HistoricalExchangeRateQuery;
use Exchanger\Service\HttpService;
use Exchanger\Service\SupportsHistoricalQueries;
class ConstantService extends HttpService
{
use SupportsHistoricalQueries;
protected function getLatestExchangeRate(ExchangeRateQuery $exchangeQuery): ExchangeRate
{
return $this->createInstantRate($exchangeQuery->getCurrencyPair(), $this->options['value']);
}
protected function getHistoricalExchangeRate(HistoricalExchangeRateQuery $exchangeQuery): ExchangeRate
{
return $this->createInstantRate($exchangeQuery->getCurrencyPair(), $this->options['value']);
}
}
Swap throws an Exchanger\Exception\ChainException. Calling $exception->getExceptions() on it returns the list of exceptions collected from each provider in the chain.
Yes. The European Central Bank and the national banks listed under Public providers require no key. A few commercial providers (cryptonator, exchangeratehost, webservicex) can also currently be used without one, since the Exchanger wrapper does not yet enforce an option for them.
Swap is the high-level, easy-to-use API. Exchanger is the lower-level provider layer Swap is built on. Reach for Exchanger directly only when you need finer control over chain composition, caching, or HTTP plumbing. See the README's Which package should I use? section.
Configure any PSR-16 cache via Builder::useSimpleCache(). See PSR-16 SimpleCache (minimal setup).
Pass ['cache' => false] as the options argument: $swap->latest('EUR/USD', ['cache' => false]).
Implement Exchanger\Contract\ExchangeRateService (or extend HttpService / Service), register it with Swap\Service\Registry::register(), then call Builder::add() with your identifier. See Creating a custom service.
In the Provider configuration section above, split into Commercial and Public tables with identifier, base currency, quote currency and historical support.
How can I help you explore Laravel packages today?