---
title: "Exchange rates — Money for Laravel | Roundly"
description: "Explicit conversion through config, database, ECB or chain drivers, exact cross rates, manual rates, custom drivers and a production setup."
url: https://roundly-consulting.com/open-source/docs/money-for-laravel/exchange-rates
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Money for Laravel](https://roundly-consulting.com/open-source/docs/money-for-laravel.md)

# 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):

```php
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. |

```php
// 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

```php
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:

```bash
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

```php
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

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/money-for-laravel.md)
