A PHP currency converter comes down to three steps: fetch exchange rates from an API and cache them, convert amounts with bcmath on decimal strings instead of floats, and round once to the target currency’s minor units. This guide builds that in plain PHP with cURL and then as a Laravel service, using the currencyapi /v3/latest endpoint. To convert single amounts without your own rate math, the currency converter API does it in one call with /v3/convert (from the Small plan).
The code needs PHP 8.2 or later with the curl, bcmath and intl extensions. It was tested with PHP 8.5 and Laravel 13 against a local server that answers with the examples from the API documentation.
Before you start
- Create a free account and copy your API key.
- Put it in an environment variable (or your
.envfile), not in the code:
export CURRENCYAPI_KEY="your-api-key"
php -m | grep -E 'bcmath|curl|intl' # all three must be listed
| Endpoint | What you get | Plans |
|---|---|---|
/v3/latest | Latest rates for any base currency | All |
/v3/historical | End-of-day rates for past dates, back to 1999 | All |
/v3/convert | Converted amounts, calculated by the API | Small and up |
Rates update daily on the Free plan, hourly on Small, every 15 minutes on Pro and every 60 seconds on Medium and up (pricing).
1. Fetch the rates with cURL
currencyApi() sends the key in the apikey header, the recommended way, and turns error answers into an exception with the API’s error code. decimalString() turns each rate back into the exact decimal the API sent, which bcmath needs:
<?php
// fx.php — PHP 8.2+ with the curl, bcmath and intl extensions
declare(strict_types=1);
final class CurrencyApiException extends RuntimeException
{
public function __construct(string $message, int $status = 0, public readonly ?string $errorCode = null)
{
parent::__construct($message, $status);
}
}
function currencyApi(string $endpoint, array $params = []): array
{
$ch = curl_init('https://api.currencyapi.com/v3/' . $endpoint . '?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['apikey: ' . getenv('CURRENCYAPI_KEY')],
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new CurrencyApiException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$json = json_decode($body, true);
if ($status !== 200 || !is_array($json)) {
$code = $json['error']['code'] ?? ($status === 422 ? 'validation_error' : 'error');
$details = isset($json['errors']) ? ' ' . json_encode($json['errors']) : '';
throw new CurrencyApiException("currencyapi $status ($code): " . ($json['message'] ?? 'no JSON body') . $details, $status, $code);
}
return $json;
}
/** A JSON number as the exact decimal string the API sent: 0.918456 → "0.918456", 8.8123E-6 → "0.0000088123". */
function decimalString(float|int $number): string
{
$shortest = json_encode($number); // shortest form that reads back as the same number
if (!preg_match('/^-?\d+(?:\.(\d+))?(?:e([-+]?\d+))?$/i', $shortest, $m)) {
throw new InvalidArgumentException("Not a finite number: $shortest");
}
$decimals = max(0, strlen($m[1] ?? '') - (int) ($m[2] ?? 0));
return number_format($number, $decimals, '.', '');
}
/** @return array{updated_at: string, rates: array<string, string>} rates in units per 1 $base */
function latestRates(string $base = 'USD'): array
{
$json = currencyApi('latest', ['base_currency' => $base]);
$rates = [];
foreach ($json['data'] as $code => $item) {
$rates[$code] = decimalString($item['value']);
}
$rates[$base] = '1'; // the base itself, also when you filter with currencies
return ['updated_at' => $json['meta']['last_updated_at'], 'rates' => $rates];
}
Why decimalString()? json_decode() returns floats, and a plain (string) cast of a small rate can come out as 8.8123E-6, which bcmath rejects. json_encode() writes the shortest form that reads back as the same number; the function expands it to plain digits. We checked it on 20,000 random rates against Python’s shortest representation: all identical.
There is no curl_close(): since PHP 8.0 the handle is freed automatically, and PHP 8.5 deprecates the function.
The errors you’ll meet (all status codes):
| Status | errorCode | Meaning |
|---|---|---|
| 401 | missing_api_key, invalid_api_key | No key sent, or the key is wrong |
| 403 | plan_upgrade_required | Endpoint or parameter not in your plan |
| 422 | validation_error (set by currencyApi()) | A parameter is invalid; the message names it |
| 429 | quota_exceeded, rate_limit_exceeded | Monthly quota used up, or more than 10 requests a minute on Free |
Errors and validation failures don’t count against your quota (rate limits).
2. Convert with bcmath and round correctly
Floats are the wrong tool for money, and bcmath alone isn’t enough either, because it cuts digits off instead of rounding:
var_export(10 * 1.0005); // 10.004999999999999
var_export(round(10 * 1.0005, 2)); // 10.0, commercial rounding gives 10.01
echo bcmul('10', '1.0005', 2); // 10.00, bcmath truncates
So convert() calculates with 20 decimals and rounds once, to the minor units of the target currency:
// fx.php (continued)
/** Decimal places of a currency, from ICU: EUR 2, JPY 0, KWD 3 */
function minorUnits(string $currency): int
{
$formatter = new NumberFormatter("en@currency=$currency", NumberFormatter::CURRENCY);
return $formatter->getAttribute(NumberFormatter::MAX_FRACTION_DIGITS);
}
/** Round half away from zero: "183.685" → "183.69". From PHP 8.4, bcround($value, $decimals) does the same. */
function roundHalfUp(string $value, int $decimals): string
{
$half = '0.' . str_repeat('0', $decimals) . '5';
return str_starts_with($value, '-')
? bcsub($value, $half, $decimals) // bcmath cuts off digits beyond the scale
: bcadd($value, $half, $decimals);
}
/** Convert $amount (a decimal string) with rates in units per 1 base unit, rounded to the minor units of $to. */
function convert(string $amount, string $from, string $to, array $rates): string
{
if (!isset($rates[$from], $rates[$to])) {
throw new InvalidArgumentException("No rate for $from or $to");
}
$value = bcdiv(bcmul($amount, $rates[$to], 20), $rates[$from], 20);
return roundHalfUp($value, minorUnits($to));
}
With the documentation’s example rates (1 USD = 0.918456 EUR = 143.5627 JPY = 0.307512 KWD):
| Call | Result |
|---|---|
convert('199.99', 'USD', 'EUR', $rates) | "183.68" |
convert('100', 'EUR', 'GBP', $rates) | "85.59", a cross rate via USD |
convert('184.14', 'USD', 'JPY', $rates) | "26436", the yen has no decimals |
convert('1000', 'USD', 'KWD', $rates) | "307.512", three decimals |
convert('-199.99', 'USD', 'EUR', $rates) | "-183.68", refunds round symmetrically |
We compared convert() with exact fraction arithmetic on 20,000 random amounts, rates and currency pairs (no differences), and roundHalfUp() with PHP 8.5’s bcround() (identical). On PHP 8.4 and later you can replace roundHalfUp() with bcround(), which rounds half away from zero by default.
Keep amounts as strings from the form or database ('199.99', or DECIMAL columns, which PDO returns as strings). If you store cents as integers, bcdiv((string) $cents, '100', 2) gives the decimal string.
3. Cache the rates and format the result
Without a cache, every page view costs a request. This file cache fetches the rates at most once per $ttl seconds and keeps serving the last rates when the API can’t be reached:
// fx.php (continued)
/** latestRates() with a file cache: one API request per $ttl seconds, the last rates when the API fails. */
function cachedLatestRates(string $base = 'USD', int $ttl = 3600): array
{
$file = sys_get_temp_dir() . "/currencyapi-latest-$base.json";
$fresh = is_file($file) && time() - filemtime($file) < $ttl;
if ($fresh) {
return json_decode(file_get_contents($file), true);
}
try {
$result = latestRates($base);
} catch (CurrencyApiException $e) {
if (is_file($file)) {
return json_decode(file_get_contents($file), true); // API down or quota used up
}
throw $e;
}
$tmp = $file . '.' . getmypid();
file_put_contents($tmp, json_encode($result));
rename($tmp, $file); // readers never see a half-written file
return $result;
}
Set $ttl to your plan’s update interval: 86400 on Free, 3600 on Small, 900 on Pro, 60 on Medium and up. The intl extension then formats the amount for your users:
<?php
// example.php
require __DIR__ . '/fx.php';
['updated_at' => $updatedAt, 'rates' => $rates] = cachedLatestRates('USD');
$eur = convert('199.99', 'USD', 'EUR', $rates); // "183.68"
$formatter = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
echo $formatter->formatCurrency((float) $eur, 'EUR'), " (rates of $updatedAt)\n"; // 183,68 €
formatCurrency() takes a float; for an already rounded amount like 183.68 that’s harmless. With en_US the same call prints €183.68, and JPY amounts come out without decimals (¥26,436). The decimals per currency are also listed on the currency pages, for example JPY and KWD.
4. Laravel: a service with caching
In Laravel, the HTTP client and the cache replace cURL and the file cache. Add the key to config/services.php:
'currencyapi' => [
'key' => env('CURRENCYAPI_KEY'),
],
Move decimalString(), minorUnits(), roundHalfUp() and convert() from above into app/Support/money.php and autoload it in composer.json (then run composer dump-autoload):
"autoload": {
"files": ["app/Support/money.php"]
}
The service caches the rates with Cache::flexible (Laravel 11.23 or later):
<?php
// app/Services/ExchangeRates.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ExchangeRates
{
/** @return array{updated_at: string, rates: array<string, string>} */
public function latest(string $base = 'USD'): array
{
// fresh for an hour; for up to a day after that the last rates are served
// while Laravel refreshes them in the background
return Cache::flexible("currencyapi:latest:$base", [3600, 86400], function () use ($base) {
$response = Http::baseUrl('https://api.currencyapi.com/v3')
->withHeaders(['apikey' => config('services.currencyapi.key')])
->timeout(10)
->retry(2, 500, fn ($e) => $e instanceof ConnectionException)
->get('latest', ['base_currency' => $base])
->throw(); // 4xx/5xx → RequestException, nothing is cached
$rates = array_map(fn (array $item) => decimalString($item['value']), $response->json('data'));
$rates[$base] = '1';
return ['updated_at' => $response->json('meta.last_updated_at'), 'rates' => $rates];
});
}
public function convert(string $amount, string $from, string $to): string
{
return convert($amount, $from, $to, $this->latest()['rates']);
}
}
The retry() only repeats connection failures; repeating a 401 or a used-up quota wouldn’t help. Once the cache holds rates, a failed refresh doesn’t break your pages: we tested it by letting the quota run out two hours after the first fetch, and the service kept returning the cached rates.
A route that validates the input and converts:
// routes/web.php
use App\Services\ExchangeRates;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
Route::get('/convert', function (Request $request, ExchangeRates $fx) {
$codes = array_keys($fx->latest()['rates']);
$input = $request->validate([
'amount' => ['required', 'decimal:0,2'],
'from' => ['required', Rule::in($codes)],
'to' => ['required', Rule::in($codes)],
]);
return [
'amount' => $fx->convert($input['amount'], $input['from'], $input['to']),
'currency' => $input['to'],
];
});
GET /convert?amount=199.99&from=USD&to=EUR returns {"amount":"183.68","currency":"EUR"}; an amount with three decimals or an unknown currency gets Laravel’s usual 422 validation response.
5. Invoices: store the rate with the amount
For invoices, orders and refunds, convert at the rate of the transaction date and save the rate and its date next to the converted amount, so you can reproduce every number later. Past rates come from /v3/historical, which takes date=2026-09-30 or up to 31 dates at once (dates=2026-08-31,2026-09-30, each date counts as one request):
$json = currencyApi('historical', ['date' => '2026-09-30', 'base_currency' => 'USD', 'currencies' => 'EUR']);
$rate = decimalString($json['data']['EUR']['value']);
To convert past amounts without code, use the historical currency converter.
Next steps
- Let the API do the multiplication:
/v3/convertreturns converted amounts on the Small plan and up. - Same converter in other languages: JavaScript and Python.
- All parameters and responses: the API documentation.