Retrieve exchange rates for a base currency using various services (online & local).
- PHP >= 8.2
- Laravel >= 10.0
Install rates using composer require:
composer require atobeach/ratesPublish the configuration file (this will create a rates.php file inside the config directory):
php artisan vendor:publish --provider="AtoBeach\Rates\RatesServiceProvider"use AtoBeach\Rates\Facades\Rates;
$rate = Rates::get();
if ($rate === null) {
// No driver in the chain produced rates.
return;
}
echo $rate->rate('EUR'); // 0.92If you'd rather throw on miss (similar to Eloquent's findOrFail):
use AtoBeach\Rates\Exceptions\RatesUnavailableException;
try {
$rate = Rates::getOrFail();
} catch (RatesUnavailableException $e) {
// ...
}
echo $rate->rate('EUR');Note: With no argument,
Rates::get()resolves the default base fromconfig('rates.base')(defaults toUSD). Set theRATES_BASEenv var to override.
$rate = Rates::get('EUR');You may pass either a string or the Currency enum:
use AtoBeach\Rates\Currency;
$rate = Rates::get(Currency::EUR);The returned Rate object exposes the resolved data:
$rate->base; // 'EUR'
$rate->rates; // ['USD' => 1.09, 'GBP' => 0.85, ...]
$rate->source; // e.g. 'European Central Bank'
$rate->date; // ISO 8601 date the rates are for
$rate->timestamp; // unix timestamp the rates were last updated
$rate->driver; // FQCN of the driver that produced the rate
$rate->fromCache; // true when the rate was served from cacheIt also exposes a few helper methods:
$rate->rate('EUR'); // 0.92 — the exchange rate for the target currency, or null if missing.
$rate->rate(Currency::EUR); // enum form
$rate->convert(100, 'EUR'); // Convert from this rate's base currency. See "Conversion".
$rate->convertMany(100, ['EUR', 'GBP']);
$rate->updatedAt(); // ?Carbon — uses timestamp, falls back to the date string.
$rate->isFresh(); // True when within the freshness window (always true if uncached).
$rate->isStale(); // True when served from cache past its freshness window.
$rate->isEmpty(); // True when no rate data was hydrated (used by the fallback chain).
$rate->toArray(); // Array form (cache metadata excluded).
json_encode($rate); // JSON-encoded form via JsonSerializable.Rate is Macroable, so you can extend it with your own helpers:
use AtoBeach\Rates\Rate;
Rate::macro('inMillions', fn () => array_map(fn ($r) => $r * 1_000_000, $this->rates));
$rate->inMillions(); // ['EUR' => 920000.0, ...]Rate::make([...]) is the static factory used to construct rates manually — handy in tests and when implementing custom drivers. Snake-cased keys are automatically camel-cased to property names.
AtoBeach\Rates\Currency is a backed enum covering the ISO 4217 currency codes. Most public APIs in the package accept either a string code or the enum case interchangeably:
use AtoBeach\Rates\Currency;
Rates::get(Currency::EUR);
Rates::convert(100, Currency::USD, Currency::JPY);
$rate->convert(100, Currency::GBP);The enum provides helpers for display and rounding:
Currency::USD->label(); // 'US Dollar'
Currency::USD->symbol(); // '$'
Currency::USD->minorUnits(); // 2 — used to round conversions correctly per ISO 4217
Currency::USD->format(1234.5); // '$1,234.50'format() uses thousands-separated decimal notation prefixed with the currency symbol. For locale-aware output (e.g. 1.234,56 € in de_DE), use Laravel's Number::currency() helper instead.
Rates ships with a fluent currency conversion API backed by bcmath for precision-safe
arithmetic. Conversions return a string by default — cast to (float) only if you accept
IEEE 754 limitations.
use AtoBeach\Rates\Facades\Rates;
Rates::convert(100, 'USD', 'EUR'); // '92.00'By default, a conversion fetches rates for the source currency. If your application warms a single rate table, configure a canonical conversion base and Rates will derive cross rates from that table instead:
// config/rates.php
'base' => 'GBP',
'bases' => ['GBP'],
'conversion' => [
'base' => 'GBP',
],Now Rates::convert(25, 'USD', 'EUR') fetches the GBP table once and computes:
($amount / $rates['USD']) * $rates['EUR'];This is useful for apps that display prices in many user currencies while keeping one consistent cached snapshot from providers like APILayer, CurrencyLayer, Open Exchange Rates or Frankfurter.
Omit the target currency to get a Conversion builder back:
Rates::convert(100)->from('USD')->to('EUR'); // '92.00'
Rates::convert(100, 'USD')->to('EUR'); // '92.00'If you already have a Rate instance, you can convert from it directly without re-fetching:
$rate = Rates::get('USD');
$rate->convert(100, 'EUR'); // '92.00'
$rate->convert(100)->to('EUR'); // builder formConvert one amount into many target currencies in a single call:
Rates::convert(100)->from('USD')->toMany(['EUR', 'GBP', Currency::JPY]);
// ['EUR' => '92.00', 'GBP' => '78.00', 'JPY' => '15523']
$rate->convertMany(100, ['EUR', 'GBP']);
// ['EUR' => '92.00', 'GBP' => '78.00']You may pass either strings or the Currency enum to from() / to(). When the target
is a Currency (or a recognised string code), the result is rounded using ISO 4217
minor units — so JPY rounds to 0 decimals, BHD to 3, and most others to 2. You can
override the precision explicitly:
use AtoBeach\Rates\Currency;
Rates::convert(100, Currency::USD, Currency::JPY); // '15234' (0 decimals)
Rates::convert(100, 'USD')->to(Currency::EUR, 4); // '92.0000'When enabled, resolved rates are cached so repeated calls don't hit the upstream API.
Rates uses a stale-while-revalidate strategy: once a cached rate passes the ttl
threshold it is served immediately while a fresh copy is fetched in the background
after the response is sent. The stale_ttl controls the absolute eviction window —
once exceeded, the next call resolves synchronously.
Caching is disabled by default. Enable it via RATES_CACHE_ENABLED=true or in
config/rates.php:
'cache' => [
'enabled' => true,
'store' => null, // null = default Laravel cache store
'prefix' => 'rates',
'ttl' => 'daily', // 'daily' | 'hourly' | integer seconds
'stale_ttl' => 60 * 60 * 6,
'cache_only' => false, // true = read from cache only, no live fetch
],The ttl may be an integer (a rolling window in seconds) or a strategy string:
'daily'— all rates expire at midnight UTC, keeping every base currency in sync for the whole day.'hourly'— all rates expire on the hour boundary.- An integer such as
3600— rolling window of N seconds from the fetch time.
Fixed boundaries like 'daily' ensure that rates for different base currencies always
come from the same refresh cycle, avoiding subtle cross-currency inconsistencies.
Drivers that know their own refresh schedule (e.g. the European Central Bank publishes
once daily at ~16:00 CET) override the configured ttl automatically so rates stay
fresh as long as possible. You can implement this on a custom driver by overriding
Driver::freshUntil(): ?int.
If you'd rather your application never makes a live API call during a
request, set cache.cache_only = true (or RATES_CACHE_ONLY=true).
In this mode:
Rates::get($base)returns the cached value, ornullon cache miss.Rates::convert(...)likewise returns null when the source rate isn't cached — your code is responsible for handling that.Rates::refresh($base)still fetches live (it's the only path that populates the cache).- The
rates:refreshcommand works as a normal scheduled job.
This is the right mode if you want predictable request latency and have a scheduler keeping the cache warm:
use Illuminate\Support\Facades\Schedule;
Schedule::command('rates:refresh')->hourly();The background refresh runs via the framework's terminating callback, so it fires after
the HTTP response is sent. In long-running processes (queue workers, daemons) the
callback may not fire until the process shuts down, so stale rates may persist until the
cache entry fully expires (stale_ttl). Use the scheduled rates:refresh command for
predictable cache warmup in those environments.
Two helpers manage cached rates explicitly:
Rates::flush(); // forget every cached base
Rates::flush('USD'); // forget a single base
Rates::refresh('USD'); // bypass the cache, resolve fresh, write throughrefresh() is useful in scheduled jobs that want to warm the cache ahead of traffic.
Both helpers are no-ops when caching is disabled.
A rates:refresh command is included to keep your cache warm. By default it
refreshes every base listed in config('rates.bases'):
// config/rates.php
'bases' => ['USD', 'EUR', 'GBP'],Tip: list every base your application fetches — including the "from" side of any conversions, since each
Rates::convert($amount, $from, $to)call fetches a fresh rate keyed by$from.
Schedule it however you like — for daily-aligned rates, run it at midnight UTC:
use Illuminate\Support\Facades\Schedule;
Schedule::command('rates:refresh')->dailyAt('00:00');You can also pass bases explicitly, bypassing the config:
php artisan rates:refresh USD EURThe command prints one line per base (✓ or ✗) and exits non-zero when any base could not be resolved, so it plays nicely with monitoring.
For post-deploy or container-boot hooks, pass --if-empty so the command only fetches
bases that aren't already cached. This is safe to run alongside a daily scheduled
rates:refresh — there's no double-fetch:
php artisan rates:refresh --if-emptyWhen every base is already cached, the command exits immediately without contacting any upstream API.
The rates:status command prints the cache entry for each configured base, so you can
introspect freshness, driver and timing without dropping into Tinker:
php artisan rates:status+------+--------+-------------+--------------------------+----------------+----------------+
| Base | State | Driver | Source | Cached | Fresh until |
+------+--------+-------------+--------------------------+----------------+----------------+
| USD | fresh | Frankfurter | European Central Bank | 12 minutes ago | in 23 hours |
| EUR | stale | Frankfurter | European Central Bank | 2 days ago | 1 day ago |
| GBP | missing| — | — | — | — |
+------+--------+-------------+--------------------------+----------------+----------------+
Pass bases as arguments to inspect a specific subset:
php artisan rates:status USD EURYou may call Rates::fake with an array of base currency patterns and their expected rates to fake the rates of a base currency:
use AtoBeach\Rates\Rate;
use AtoBeach\Rates\Facades\Rates;
Rates::fake([
'USD' => Rate::make([
'rates' => ['EUR' => 0.92, 'GBP' => 0.78],
'source' => 'European Central Bank',
// ...
])
]);
// Somewhere in your application...
$rate = Rates::get('USD'); // RateIf you prefer, you may use an asterisk to return the same rates for any base currency that is given:
Rates::fake([
'*' => Rate::make([
'rates' => ['EUR' => 0.92, 'GBP' => 0.78],
// ...
])
]);
$rate = Rates::get($anyBase); // RateIf no expectations are given, or an expectation is not matched, Rates::get will return null:
Rates::fake();
Rates::get($anyBase); // nullIf your application attempts to retrieve rates for multiple base currencies, you may provide multiple base currency expectation patterns:
Rates::fake([
'USD' => Rate::make([
'rates' => ['EUR' => 0.92, 'GBP' => 0.78],
// ...
]),
'EUR' => Rate::make([
'rates' => ['USD' => 1.09, 'GBP' => 0.85],
// ...
]),
]);You may also use an asterisk to fake several base currency patterns:
Rates::fake([
'US*' => Rate::make([
'rates' => ['EUR' => 0.92],
// ...
]),
'EU*' => Rate::make([
'rates' => ['USD' => 1.09],
// ...
]),
]);When using a canonical conversion base, fake that base because conversions derive cross rates from the configured table:
config(['rates.conversion.base' => 'GBP']);
$fake = Rates::fake([
'GBP' => Rate::make(['rates' => ['USD' => 1.25, 'EUR' => 1.15]]),
]);
Rates::convert(25, 'USD', 'EUR'); // '23.00'
$fake->assertGot('GBP');Rates::fake() returns a RatesFake you can assert against — similar to Http::fake() / Http::assertSent():
$fake = Rates::fake([
'USD' => Rate::make(['rates' => ['EUR' => 0.92]]),
]);
// ... your code under test ...
$fake->assertGot('USD'); // a fetch happened for USD
$fake->assertGot(Currency::USD); // enum form
$fake->assertGot(fn ($base) => str_starts_with($base, 'US')); // closure truth test
$fake->assertNotGot('GBP'); // no fetch for GBP
$fake->assertNothingFetched(); // no fetches at allAvailable drivers:
- Frankfurter - Default
- ExchangeRate-API
- Open Exchange Rates
- Fixer
- CurrencyLayer
- exchangerate.host
- Apilayer Currency Data
- Fawaz Ahmed Currency API
- European Central Bank
- Config
It is encouraged to set up the European Central Bank driver as a fallback driver using a local reference file so that some rate information is returned in the event of hitting a rate limit or outage from the external web services.
To set up the European Central Bank driver to retrieve rates from your own server, you must:
- Run
php artisan rates:updateto download the latesteurofxref-daily.xmlfile into yourdatabase/ecbdirectory - That's it, you're all set!
Note: Keep in mind, you'll need to update this file by running
rates:updateon a regular basis to retrieve the most current rates.
In the config file, you can specify as many fallback drivers as you wish. It is recommended to configure the European Central Bank driver with the local reference file (mentioned above), so you are always retrieving some generic rate information.
If an exception occurs trying to grab a driver (such as a 400/500 error if the providers API changes), it will automatically use the next driver in line.
The Config driver reads rates from your config/rates.php file directly, without
performing any HTTP requests. This is useful as a deterministic fallback, or
when you prefer to manage a small set of known rates yourself:
// config/rates.php
'config' => [
'source' => 'Static Configuration',
'date' => '2024-01-01',
'bases' => [
'USD' => [
'EUR' => 0.92,
'GBP' => 0.78,
],
],
],Drivers are referenced by short string names in config/rates.php:
| Name | Class |
|---|---|
frankfurter |
AtoBeach\Rates\Drivers\Frankfurter |
exchange-rate-api |
AtoBeach\Rates\Drivers\ExchangeRateApi |
openexchangerates |
AtoBeach\Rates\Drivers\OpenExchangeRates |
fixer |
AtoBeach\Rates\Drivers\Fixer |
currencylayer |
AtoBeach\Rates\Drivers\CurrencyLayer |
exchangerate-host |
AtoBeach\Rates\Drivers\ExchangeRateHost |
apilayer-currency-data |
AtoBeach\Rates\Drivers\ApilayerCurrencyData |
fawazahmed |
AtoBeach\Rates\Drivers\FawazAhmed |
ecb |
AtoBeach\Rates\Drivers\EuropeanCentralBank |
config |
AtoBeach\Rates\Drivers\Config |
The driver layer follows Laravel's manager pattern, with a public contract and a template-method base class:
AtoBeach\Rates\Contracts\Driver— the interface the manager depends on (get()andfreshUntil()).AtoBeach\Rates\Drivers\Driver— the abstract template-method base. Handles orchestration ofget()and exposes two hooks:process()(fetch raw rate data) andhydrate()(populate theRateobject).AtoBeach\Rates\Drivers\HttpDriver— extends the abstractDriverand providesprocess()for you, so HTTP-backed drivers only needurl()andhydrate().
To create your own driver, extend the abstract base:
namespace App\Rates\Drivers;
use AtoBeach\Rates\Drivers\Driver;
use AtoBeach\Rates\Rate;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Fluent;
class MyDriver extends Driver
{
protected function process(string $base): ?Fluent
{
$response = Http::get('https://driver-url.com', ['base' => $base]);
return new Fluent($response->json());
}
protected function hydrate(Rate $rate, Fluent $data): Rate
{
$rate->rates = (array) $data->rates;
return $rate;
}
}Return null from process() to skip the driver and let the chain fall through to the next fallback.
Register it on the manager (typically in a service provider's boot()),
then reference its name in the config:
// AppServiceProvider::boot()
Rates::extend('my-driver', fn () => new \App\Rates\Drivers\MyDriver);// config/rates.php
'driver' => 'my-driver',If you're fetching rates using an HTTP service, extend HttpDriver to reduce
boilerplate:
namespace App\Rates\Drivers;
use AtoBeach\Rates\Drivers\HttpDriver;
use AtoBeach\Rates\Rate;
use Illuminate\Support\Fluent;
class MyDriver extends HttpDriver
{
public function url(string $base): string
{
return "http://driver-url.com?base=$base";
}
protected function hydrate(Rate $rate, Fluent $data): Rate
{
$rate->rates = (array) $data->rates;
return $rate;
}
}If you need full control over rate resolution (skipping the template method
entirely), implement the Driver contract directly:
namespace App\Rates\Drivers;
use AtoBeach\Rates\Contracts\Driver;
use AtoBeach\Rates\Rate;
class MyDriver implements Driver
{
public function get(string $base): ?Rate
{
// your own resolution logic
}
public function freshUntil(): ?int
{
return null;
}
}Rates is versioned under the Semantic Versioning guidelines as much as possible.
Releases will be numbered with the following format:
<major>.<minor>.<patch>
And constructed with the following guidelines:
- Breaking backward compatibility bumps the major and resets the minor and patch.
- New additions without breaking backward compatibility bumps the minor and resets the patch.
- Bug fixes and misc changes bumps the patch.
Minor versions are not maintained individually, and you're encouraged to upgrade through to the next minor version.
Major versions are maintained individually through separate branches.