NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Money for Laravel

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

DriverClassBehaviour
configArrayExchangeRateProviderStatic rates from exchange.providers.config.rates — direct, inverse, then triangulated through the pivot. Date-agnostic: it answers for any date and never goes stale.
databaseDatabaseExchangeRateProviderNewest row on or before the date, direct or inverse; through the pivot when that is missing or stale. Needs the published migration.
ecbEcbExchangeRateProviderECB euro reference rates — daily feed for the latest, 90-day feed for recent dates. Cached, zero setup, and a refresh source.
chainChainExchangeRateProviderTries 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 weekdays

Manual 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 date

Every 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 crypto

By 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.