florianv/swap
Swap is an easy-to-use PHP currency conversion library that retrieves exchange rates from 30+ providers behind a single API, with PSR-16 caching, historical rates, and automatic provider fallback. PHP 8.2+, maintained since 2014.
Install. For a plain PHP service, pull in Swap plus a PSR-18 client and PSR-17 factory — Swap deliberately ships neither:
composer require florianv/swap symfony/http-client nyholm/psr7
Inside a Laravel app the pragmatic route is the framework wrapper, which wires the builder, config and facade for you:
composer require florianv/laravel-swap
It auto-registers a service provider, exposes a Swap facade and publishes config/swap.php where you declare the provider chain (service identifier + options). Either way you need PHP 8.2+ — check composer.json before you start on a legacy 8.1 app.
Where to look first, in order:
$swap->latest('EUR/USD') in one snippet.doc/readme.md → Configuration → How the fallback chain works, then Caching. These two sections determine your production behaviour more than anything else.First use case — a single conversion in a pricing helper. Don't sprinkle (new Builder()) through your codebase; build once, inject everywhere:
// config/swap.php (laravel-swap)
'providers' => [
['service' => 'fastforex', 'options' => ['api_key' => env('FASTFOREX_API_KEY')]],
['service' => 'european_central_bank'], // free fallback for EUR pairs
],
// app/Services/Fx.php
public function rate(string $base, string $quote): float
{
$rate = Swap::latest("{$base}/{$quote}"); // 'EUR/USD' => USD per 1 EUR
return $rate->getValue();
}
public function convert(float $amount, string $base, string $quote): float
{
return $amount * $this->rate($base, $quote);
}
Note the direction: latest('EUR/USD') answers "how many USD for one EUR". Swap never inverts and never multiplies — the arithmetic is yours. Persist getProviderName() and getDate() alongside anything financial; you will want them in the audit trail.
1. Bind the built instance as a singleton, not the builder. Constructing Swap per call re-parses config and re-resolves HTTP client discovery. In Laravel, register the built Swap (or your Fx wrapper) in a service provider and inject the interface. This also gives you one obvious place to swap the client in tests.
2. Bridge Laravel's cache into Swap's PSR-16 slot. Illuminate\Cache\Repository already implements Psr\SimpleCache\CacheInterface, so you get Redis/database/file caching for free:
$swap = (new Builder())
->add('fastforex', ['api_key' => config('services.fastforex.key')])
->useCache(Cache::store('redis'))
->build();
Prefer this over hand-rolling a cache layer — Swap keys per provider/pair/date, which is hard to reproduce correctly.
3. Always configure a chain, and order it by cost. Primary commercial provider first (rich pair coverage, real-time), then a free public feed as a safety net, e.g. fastforex → european_central_bank. Keep in mind the chain retries on errors, so a dead primary adds a full HTTP timeout to every request — set a per-query timeout/verify_ssl style option rather than accepting the default when latency matters.
4. Snapshot the rate at transaction time. For invoices, orders and ledgers, never recompute later: store amount_base, amount_quote, rate_value, rate_date, rate_provider on the row. Use historical($pair, $date) for backfill/reconciliation of past periods, and treat the stored snapshot as authoritative even if the provider later revises the figure.
5. Cache-warm on a schedule. A rate is not request-scoped data. Add a scheduled command that fetches your handful of real currency pairs every 15–60 minutes into the cache; web requests then hit cache almost always, and free-tier request quotas stop mattering. Same idea for historical backfill jobs, which should run in a queue with retries.
6. Test with a mock PSR-18 client, not mocks of Swap. Because useHttpClient() accepts any PSR-18 client, symfony/http-client's MockHttpClient with recorded provider JSON lets you exercise the real parser, the real fallback chain and the real cache — a much better signal than stubbing getValue(). For pure domain tests, implement ExchangeRateService with a fixed-rate stub and register it via addExchangeRateService(); that's also the extension point for a bespoke internal rates service.
7. Use the moneyphp adapter for anything that touches accounting. Swap returns a float. For totals, tax and rounding, feed rates into moneyphp/money via the SwapExchange adapter (or parse the float into a decimal string with bcmath/brick/math) rather than chaining float multiplications.
8. Display-layer vs. settlement-layer separation. Storefront price display can tolerate a cached rate a few hours old; settlement cannot. Model them as two methods on your Fx service — displayRate() (cached, forgiving, falls back to last known) and settlementRate() (short TTL, no fallback to stale data, throws loudly). One config should not serve both.
The HTTP client is not bundled — and Composer will fight you. After installing a client you may hit the php-http/discovery plugin prompt. Add it explicitly:
"config": { "allow-plugins": { "php-http/discovery": true } }
Without it, auto-discovery silently finds nothing and you get a confusing "no client" error at runtime rather than install time.
Unsupported pairs are skipped silently. Only when every provider fails do you get a ChainException. That means a typo in a currency code, or asking the ECB for USD/JPY, looks identical to a network outage from the caller's perspective. Catch the exception, iterate the collected errors (getErrors()), and separate "unsupported pair" from "provider down" — they need different remediation and different alerting.
No automatic cross-conversion. If your chain is EUR-anchored only, GBP/CHF will fail even though both legs exist. Either pay for a provider with arbitrary base support, or implement cross-rates yourself (GBP/EUR × EUR/CHF) with explicit rounding rules — and be aware that compounding two rates doubles your error and your latency.
Public feeds have business-day granularity. ECB publishes once per working day, around 16:00 CET, and not on weekends or TARGET holidays. A historical() call for a Saturday will not give you a Saturday rate. Implement a "walk back to the previous business day" helper once, in your Fx service, rather than in five call sites.
Timezones on historical dates. The date you pass is interpreted by the provider. A rate for "2024-01-15" from a Tokyo-anchored feed versus an ECB feed may not cover the same trading window. For anything reconciled, store the provider name alongside the rate so a reviewer can explain a one-day discrepancy.
Floats and precision. getValue() is a float; six-ish decimals is typical for majors and much worse for exotics. Never round-trip money through it more than necessary, and define your display rounding in one place — the rate itself should not be pre-rounded, or your totals drift.
Cache key collisions across config changes. If you switch primary providers or change a cache TTL, the underlying keys shift and you can be quietly serving stale numbers from an old provider (or missing cache entirely for a while after deploy). Use an explicit per-query cache_key/TTL override for the pairs that matter, and flush deliberately on provider cutover.
Don't call it in a loop. The classic multi-currency storefront bug: rendering a product grid and issuing one latest() per row. Even with caching this is wasteful and, on a cache miss storm, will blow through free-tier quotas in seconds. Fetch the distinct pairs once per request/schedule, or warm them ahead of time.
API keys belong in env, read through config. In a Laravel app with config:cache, getenv() inside a config file is fine; getenv() inside application code is not — it returns false after caching. Keep the env() call in config/swap.php only.
Extension point for the future: if you outgrow Swap's opinionated defaults (custom chain composition, bespoke retry/circuit-breaker behaviour, non-standard caching), the same providers live in the lower-level florianv/exchanger library. Designing your Fx wrapper as a thin interface now makes that migration a one-class change later.
How can I help you explore Laravel packages today?