worksome/exchange
Currency exchange for Laravel: convert money between currencies through a clean, driver-based API. Swap exchange-rate providers, cache rates, and keep conversion logic testable and framework-friendly.
worksome/exchange is a Laravel wrapper around currency-rate APIs (Frankfurter, ExchangeRate.host, Fixer, CurrencyAPI, etc.) that gives you a single Exchange facade, a normalised Rates value object, and framework-native caching.
Minimum setup:
composer require worksome/exchangephp artisan vendor:publish --tag="exchange-config" — this gives you config/exchange.php with driver, cache, and per-driver drivers.* blocks..env (e.g. EXCHANGE_DRIVER=frankfurter, plus any API key the driver needs). Frankfurter needs no key; Fixer/CurrencyAPI/OpenExchangeRates do.config/exchange.php (what's actually enabled), the Worksome\Exchange\Facades\Exchange facade, and Worksome\Exchange\Rates. Those three files are the whole public surface you'll touch day to day.First real use case — price a basket in the user's currency:
use Worksome\Exchange\Facades\Exchange;
$rates = Exchange::getRates('GBP'); // Rates DTO for a base currency
$rates->baseCurrency; // 'GBP' (string)
$rates->rates; // ['USD' => 1.27, 'EUR' => 1.17, ...]
$rates->retrievedAt; // Carbon instance
Upgrading from v2.x — do this before anything else:
$rates->getBaseCurrency(), $rates->getRates(), $rates->getRetrievedAt() call with the public properties baseCurrency, rates, retrievedAt. These getters are gone in v3.0.0, not deprecated. grep -rn "getBaseCurrency\|getRates()\|getRetrievedAt" app/ tests/ first.FRANKFURTER_BASE_URL from the /v1 to the /v2 endpoint — otherwise every call throws.frankfurter_v2 driver you can select.Wrap the facade in your own service. Controllers should never call Exchange directly; you want one seam to fake, cache, and add fallbacks to.
final class CurrencyConverter
{
public function convert(int $amountInMinorUnits, string $from, string $to): int
{
$rates = Exchange::getRates($from);
return (int) round($amountInMinorUnits * $rates->rates[$to]);
}
}
Persist rates, don't fetch per request. The DTO is cheap but the HTTP call is not. Either rely on the package cache config (cache.store, TTL) or store a Rates payload in your own table/Redis key and hydrate on boot. Cache key by base currency, since that's the granularity the API gives you.
Stamp your UI with retrievedAt. Because the property is a real Carbon, it's trivial to render "rates as of …" or to refuse to quote if retrievedAt->diffInHours() > 12. This is the single most useful thing the DTO gives you in production.
Refresh out of band. A scheduled command that pulls a handful of base currencies and writes them to your store beats lazy fetching — it keeps request latency flat and turns API outages into a stale-rate warning rather than a 500.
// app/Console/Commands/RefreshRates.php
foreach (config('shop.base_currencies') as $base) {
Cache::put("rates:{$base}", Exchange::getRates($base), now()->addHour());
}
Multi-currency display. Store amounts in a single base currency (integer minor units) and convert at read time. Never store converted values alongside the base — you'll be re-deriving them the moment rates move.
Testing. Swap the driver to a test/stub driver in phpunit.xml, or Http::fake() the underlying driver endpoint and assert on the parsed Rates object. Because Rates is now property-only, assertions become simple array/equality checks:
Http::fake([/* driver endpoint */ => Http::response([...])]);
$rates = Exchange::getRates('GBP');
expect($rates->rates)->toHaveKey('USD')
->and($rates->baseCurrency)->toBe('GBP');
Custom drivers. The manager resolves drivers from the drivers array in config; registering a bespoke provider there is the supported extension point when you have an internal FX service or a vendor the package doesn't cover.
$rates->getRates() on v3 throws BadMethodCallException/Error depending on how it's reached — and because rates is a public property, a typo'd $rates->rate returns null with no warning. Add a static analysis pass (phpstan/larastan) after the upgrade; property access is much harder to catch by grep than a method call.baseCurrency is now cast to float (per the v3 changelog, "ensure base currency is float"). If you'd been relying on an int or string there — === comparisons, strtoupper() calls, JSON shape — check those call sites. Loosen strict comparisons around it.FRANKFURTER_BASE_URL=/v2 produces 404s that look like auth/network errors. Confirm the endpoint in config/exchange.php and in your deployed .env — env values beat config defaults, and .env files are the usual place this gets missed.frankfurter_v2 driver and the updated frankfurter driver overlap. Pick one deliberately; having both configured with different base URLs is a great way to get inconsistent rates across queue workers.$rates->rates gives you floats. Convert into brick/money, Money\Money, or integer minor units before you multiply, sum, or persist. Rounding drift compounds fast on order totals and invoices.rates. Don't assume $rates->rates['GBP'] exists when the base is GBP — short-circuit if ($from === $to) return $amount; before indexing. Missing keys are a null/undefined-index warning away from a wrong price.EXCHANGE_DRIVER or changing a base URL does not invalidate previously cached rates. Flush the relevant cache keys on deploy whenever you change driver, base URL, or upgrade a major version.retrievedAt is your debugging lever. When a number looks wrong, dump baseCurrency, retrievedAt, and the relevant key before you suspect the maths; nine times out of ten it's a stale cache entry or a base-currency mismatch.How can I help you explore Laravel packages today?