amrshawky/laravel-currency
amrshawky/laravel-currency is a Laravel package for currency conversion, formatting, and exchange rates. It provides a simple API, configurable exchange-rate providers, caching, and locale-aware formatting to help build multi-currency applications quickly.
composer require amrshawky/laravel-currency
php artisan vendor:publish --tag=config # publishes config/currency.php
Open config/currency.php first — it is the only place to set the API key, base URL, and HTTP timeout. Add the key to .env (the key is created on exchangerate.host; the free tier now requires one, so the "no config" examples you'll find in blog posts from 2021 will fail with 401/403):
EXCHANGE_RATES_API_KEY=xxxxxxxxxxxxxxxx
Confirm the exact env var name in the published config — the package has shipped under both EXCHANGE_RATES_API_KEY and CURRENCY_API_KEY naming across versions.
The first use case is almost always a one-off conversion. The API is a fluent builder, every chain ends in ->get():
use AmrShawky\LaravelCurrency\Facade\Currency;
$eur = Currency::convert()
->from('USD')
->to('EUR')
->amount(100)
->get(); // float, e.g. 92.41
That single call is enough to validate credentials, network egress from your app host, and that the base URL in config still resolves. Do it from php artisan tinker before wiring anything into a controller.
Fluent chains, one call per concern. The builder is stateless per chain, so compose freely:
// Latest snapshot, multiple targets in one request
Currency::rates()
->latest()
->base('USD')
->symbols(['EUR', 'GBP', 'JOD'])
->get(); // ['EUR' => 0.92, 'GBP' => 0.79, ...]
// Historical (single date)
Currency::rates()
->historical('2023-01-15')
->base('USD')
->symbols(['EUR'])
->get();
// Time series — use for charts/sparklines
Currency::timeSeries()
->dateFrom('2023-01-01')
->dateTo('2023-01-31')
->base('USD')
->symbols(['EUR'])
->get();
// Fluctuation — start/end/change per symbol
Currency::fluctuation()
->dateFrom('2023-01-01')
->dateTo('2023-01-31')
->base('USD')
->symbols(['EUR'])
->get();
// Crypto uses the same convert endpoint
Currency::convert()->from('BTC')->to('USD')->amount(1)->get();
Wrap it in a service, not in controllers. The package has no caching, no retry, and no test double. Put one class between the app and the facade so you can swap it later (the package is archived; see gotchas):
class ExchangeRates
{
public function convert(float $amount, string $from, string $to): float
{
$key = "fx:{$from}:{$to}:" . now()->format('Y-m-d-H');
return Cache::remember($key, now()->addHour(), fn () =>
Currency::convert()->from($from)->to($to)->amount($amount)->get()
);
}
}
Cache aggressively. Rates change daily at best; hitting the API per request will burn your quota and add 200–800 ms to every page. Cache the rate, not the converted amount, so amount changes stay cheap:
$rates = Cache::remember("fx:USD", 3600, fn () =>
Currency::rates()->latest()->base('USD')->symbols(['EUR','GBP'])->get()
);
$price = $amount * $rates['EUR'];
Store the rate used on the transaction. For invoices, orders, or anything financial, persist amount, converted_amount, rate, base, target, and fetched_at. You cannot reconstruct which rate was applied later, and historical endpoints are the wrong tool for auditing past business transactions.
Prefer symbols([...]) over N calls. One request with an array is cheaper and returns a single consistent timestamp. Loop only when you need per-pair bases.
Handle failures at the boundary. Wrap the call once in the service class and convert transport/API failures into a domain exception your controller understands:
try {
$rate = Currency::convert()->from($from)->to($to)->amount(1)->get();
} catch (\Throwable $e) {
report($e);
throw new ExchangeRateUnavailable($from, $to, previous: $e);
}
Testing. In tests, mock the facade (Currency::shouldReceive('convert->from->to->amount->get')->andReturn(...)) is painful because of the intermediate builders. It is far easier to fake your own ExchangeRates interface in the container and let the package only ever be exercised in an integration test guard-tagged @group external.
exchangerate.host changed hands and moved to a paid/apilayer-backed model requiring an access_key; the URL has moved from api.exchangerate.host to api.exchangerate.host with auth (and at times api.apilayer.com/exchangerate_data). If calls 401/403, check config/currency.php's base_url against the provider's current docs. Overriding base_url in config (or via .env) is the intended escape hatch and doesn't require forking.brick/money, moneyphp/money, the ECB feed, or direct Guzzle calls is a one-file change.->get() is a live HTTP call. Add Cache::remember and, for resilience, a retry in a wrapper (retry(3, fn () => ..., 200) over your service method). The package's own timeout is a config value — set it low (3–5 s) so a slow API doesn't stall web requests.null/empty arrays depending on the response shape, not as a CurrencyException you can rely on. Always null-check the result before doing arithmetic; null * 100 silently becomes 0.0.Y-m-d strings; passing a Carbon cast with a timezone may serialize oddly. Format explicitly ($date->toDateString()). Historical data availability is bounded by the provider — very old dates may return empty rather than error.->base(), you get the provider's default (usually USD) — set it explicitly everywhere to avoid silent drift when the provider changes defaults.Http::withToken(...). For debugging, Http::fake() + Http::assertSent() in an integration test is more informative than dumping the builder.AmrShawky\LaravelCurrency\CurrencyServiceProvider and alias the Currency facade in config/app.php manually; otherwise you'll get "Class Currency not found" in tinker.How can I help you explore Laravel packages today?