Exchange rates
Arithmetic never converts implicitly. Conversion is an explicit call that asks the configured driver for a rate and rounds once (money.exchange.rounding, half_even by default):
use RoundlyConsulting\Money\Currency;
use RoundlyConsulting\Money\Facades\Exchange;
$money = Money::ofMajor('10', 'EUR');
$money->convertTo('USD'); // default driver (ecb), latest rate
$money->convertTo('USD', now()->subDay()); // historical
$money->convertTo('USD', rounding: RoundingMode::HalfAwayFromZero);
$conversion = Exchange::convertWithRate($money, 'CZK');
$conversion->original; // 10.00 EUR
$conversion->converted; // the CZK amount
$conversion->rate; // ExchangeRate: from, to, rate (a Ratio), date, source
Exchange::rate(Currency::of('EUR'), Currency::of('USD')); // default driver
Exchange::driver('database')->rate(Currency::of('EUR'), Currency::of('USD'));Drivers
| Driver | Class | Behaviour |
|---|---|---|
config | ArrayExchangeRateProvider | Static rates from exchange.providers.config.rates — direct, inverse, then triangulated through the pivot. Date-agnostic: it answers for any date and never goes stale. |
database | DatabaseExchangeRateProvider | Newest row on or before the date, direct or inverse; through the pivot when that is missing or stale. Needs the published migration. |
ecb | EcbExchangeRateProvider | ECB euro reference rates — daily feed for the latest, 90-day feed for recent dates. Cached, zero setup, and a refresh source. |
chain | ChainExchangeRateProvider | Tries exchange.chain in order; only “no rate” and “fetch failed” fall through to the next driver. |
// config/money.php
'exchange' => [
'default' => env('MONEY_EXCHANGE_DRIVER', 'ecb'),
'chain' => ['database', 'ecb'], // what the chain driver tries, in order
'pivot' => 'EUR', // triangulation currency for config + database
'providers' => [
'config' => [
'rates' => [
'EUR' => ['USD' => '1.0854', 'CZK' => '25.10'], // decimal strings
],
],
],
],A requested date is read as its own calendar day — the Y-m-d of the date in its own timezone, exactly how a rate is written — and money.exchange.timezone decides what “today” is for undated lookups. The database and ecb drivers use the newest rate on or before that day — so weekends, holidays and requests before the ECB’s ~16:00 CET publication use the last published day — refuse a date that has not begun yet, and treat a newest rate older than max_age_days as stale rather than serving it forever. The config driver is date-agnostic: it answers for any date and never goes stale.
Exact rates
use RoundlyConsulting\Money\Exchange\ExchangeRate;
$eurUsd = ExchangeRate::fromDecimal('EUR', 'USD', '1.0854', now(), source: 'manual');
$usdJpy = ExchangeRate::fromDecimal('USD', 'JPY', '147.25', now());
$eurUsd->rate; // Ratio 5427/5000 — exact, never a float
$eurUsd->invert(); // USD → EUR, exact
$eurUsd->through($usdJpy); // exact cross rate EUR → USD → JPY
$eurUsd->convert(Money::ofMajor('10', 'EUR')); // 10.85 USD — one rounding (HalfEven)
$eurUsd->decimal(4); // "1.0854"Rates are Ratios — never floats or pre-rounded decimals — so ECB cross rates and inversions stay exact, and only the final converted amount rounds.
Production setup
The default driver is ecb because the rates table is publish-only. For production, store rates locally and fall back to the ECB:
php artisan vendor:publish --tag="money-migrations"
php artisan migrate
# .env
MONEY_EXCHANGE_DRIVER=chain # database first, ECB as the fallback
MONEY_EXCHANGE_SCHEDULE=true # refresh from the ECB at 16:30 Berlin time on weekdaysManual rates
use RoundlyConsulting\Money\Facades\Exchange;
Exchange::rates()->manual('EUR', 'CZK', '25.10'); // effective today in money.exchange.timezone
Exchange::rates()->manual('EUR', 'CZK', '25.10', $date); // or on a given dateEvery write — manual rates and refreshes alike — goes through one path, StoreExchangeRatesAction, behind Exchange::rates()->store() and manual(). It upserts on (base, quote, effective date); a manual row is never overwritten by a refresh, and a rate without an exact decimal of up to 40 characters is refused rather than rounded. Running a refresh twice keeps one row per pair and date. The full rates() API is under The Exchange & Currencies facades.
Custom drivers
use Carbon\CarbonInterface;
use RoundlyConsulting\Money\Contracts\ExchangeRateProvider;
use RoundlyConsulting\Money\Contracts\ExchangeRateSource;
use RoundlyConsulting\Money\Currency;
use RoundlyConsulting\Money\Exchange\ExchangeRate;
use RoundlyConsulting\Money\Facades\Exchange;
final class FixerProvider implements ExchangeRateProvider, ExchangeRateSource
{
public function __construct(private FixerClient $client) {} // your own API client
public function rate(Currency $from, Currency $to, ?CarbonInterface $on = null): ExchangeRate
{
$decimal = $this->client->rate($from->code, $to->code, $on); // e.g. "1.0854"
return ExchangeRate::fromDecimal($from, $to, $decimal, $on ?? now(), source: 'fixer');
}
public function name(): string
{
return 'fixer';
}
public function fetch(CarbonInterface $from, CarbonInterface $to): iterable
{
foreach ($this->client->timeseries($from, $to) as $row) {
yield ExchangeRate::fromDecimal($row['base'], $row['quote'], $row['rate'], $row['date'], source: 'fixer');
}
}
}
// In a service provider's boot()
Exchange::extend('fixer', fn ($app) => new FixerProvider($app->make(FixerClient::class)));Host drivers are cached automatically. Implement ExchangeRateSource as well and money:rates:refresh fixer can fetch them into the rates table.
Caching and the rates table
database, ecb and custom drivers are wrapped in a cache when exchange.cache.enabled is on — only successful lookups are cached, stored as scalars. Every write to the rates table — store(), manual(), a refresh or a prune — invalidates the cached lookups, so the next conversion reads the new rows. The ECB feed is cached for providers.ecb.cache_ttl, but a refresh always downloads fresh. The money_exchange_rates table stores rates as exact decimal strings; its model, CurrencyRate, is swappable via exchange.providers.database.model and offers the pair() and effectiveOnOrBefore() scopes.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.