Skip to content

atobeach/rates

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Rates

Retrieve exchange rates for a base currency using various services (online & local).

Build status Number of downloads MIT license

Index

Requirements

  • PHP >= 8.2
  • Laravel >= 10.0

Installation

Install rates using composer require:

composer require atobeach/rates

Publish the configuration file (this will create a rates.php file inside the config directory):

php artisan vendor:publish --provider="AtoBeach\Rates\RatesServiceProvider"

Usage

Retrieve rates for the default base currency

use AtoBeach\Rates\Facades\Rates;

$rate = Rates::get();

if ($rate === null) {
    // No driver in the chain produced rates.
    return;
}

echo $rate->rate('EUR'); // 0.92

If 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 from config('rates.base') (defaults to USD). Set the RATES_BASE env var to override.

Retrieve rates for a specific base currency

$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 cache

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

Currency enum

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.

Conversion

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.

One-shot conversion

use AtoBeach\Rates\Facades\Rates;

Rates::convert(100, 'USD', 'EUR'); // '92.00'

Canonical base conversion

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.

Fluent builder

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'

Converting from an existing Rate

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 form

Bulk conversion

Convert 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']

Currency enum and rounding precision

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'

Caching

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
],

TTL strategies

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.

Cache-only mode

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, or null on 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:refresh command 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();

Long-running processes

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.

Flushing and refreshing

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 through

refresh() is useful in scheduled jobs that want to warm the cache ahead of traffic. Both helpers are no-ops when caching is disabled.

Scheduled refresh command

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 EUR

The command prints one line per base (✓ or ✗) and exits non-zero when any base could not be resolved, so it plays nicely with monitoring.

Bootstrap warmup

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-empty

When every base is already cached, the command exits immediately without contacting any upstream API.

Inspecting cache state

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 EUR

Testing

You 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'); // Rate

If 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); // Rate

If no expectations are given, or an expectation is not matched, Rates::get will return null:

Rates::fake();

Rates::get($anyBase); // null

If 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');

Assertions

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 all

Drivers

Available Drivers

Available drivers:

Setting up the European Central Bank driver (optional)

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:

  1. Run php artisan rates:update to download the latest eurofxref-daily.xml file into your database/ecb directory
  2. That's it, you're all set!

Note: Keep in mind, you'll need to update this file by running rates:update on a regular basis to retrieve the most current rates.

Fallback Drivers

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.

Config Driver

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,
        ],
    ],
],

Built-in driver names

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

Creating your own drivers

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() and freshUntil()).
  • AtoBeach\Rates\Drivers\Driver — the abstract template-method base. Handles orchestration of get() and exposes two hooks: process() (fetch raw rate data) and hydrate() (populate the Rate object).
  • AtoBeach\Rates\Drivers\HttpDriver — extends the abstract Driver and provides process() for you, so HTTP-backed drivers only need url() and hydrate().

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;
    }
}

Versioning

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.

About

Retrieve exchange rates for a base currency using various services (online & local).

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages