Tutorial · PHP

PHP currency converter in plain PHP and Laravel

Get exchange rates in PHP, cache them, convert amounts with bcmath instead of floats, round them correctly and format them with intl, in plain PHP and in Laravel.

Updated October 29, 2026 · 9 min read

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

  1. Create a free account and copy your API key.
  2. Put it in an environment variable (or your .env file), not in the code:
export CURRENCYAPI_KEY="your-api-key"
php -m | grep -E 'bcmath|curl|intl'   # all three must be listed
EndpointWhat you getPlans
/v3/latestLatest rates for any base currencyAll
/v3/historicalEnd-of-day rates for past dates, back to 1999All
/v3/convertConverted amounts, calculated by the APISmall 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):

StatuserrorCodeMeaning
401missing_api_key, invalid_api_keyNo key sent, or the key is wrong
403plan_upgrade_requiredEndpoint or parameter not in your plan
422validation_error (set by currencyApi())A parameter is invalid; the message names it
429quota_exceeded, rate_limit_exceededMonthly 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):

CallResult
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

Frequently asked questions

How do I get exchange rates in PHP?

Call an exchange rate API such as currencyapi with cURL, Guzzle or Laravel's HTTP client, decode the JSON and read the rate for your currency. Cache the response, so you don't call the API for every page view.

How do I avoid rounding errors with money in PHP?

Don't calculate with floats: 10 * 1.0005 is 10.004999999999999 and round() makes 10.0 of it. Use bcmath with decimal strings (or a money library such as brick/money or moneyphp/money) and round once at the end.

Why does bcmul() give me the wrong last cent?

bcmath functions cut off digits beyond the scale instead of rounding: bcmul('10', '1.0005', 2) returns 10.00, not 10.01. Calculate with more decimals and round yourself, or use bcround() on PHP 8.4 and later.

Is there an official PHP package for currencyapi?

Yes, everapi/currencyapi-php on Packagist (see the SDK documentation). Like json_decode(), it gives you rates as floats, so turn them into decimal strings with decimalString() from this guide before you calculate.

Can I use the free plan for a shop?

The Free plan, with 300 requests a month and daily updates, is for testing and personal projects. A commercial shop needs a paid plan, starting with Small (hourly updates).

Exchange rate API

Get these rates in your app

currencyapi delivers real-time and historical exchange rates for 170+ currencies, crypto and metals as JSON, updated as often as every 60 seconds.

Read the documentation

300 free requests every month. No credit card required.

GET https://api.currencyapi.com/v3/latest?base_currency=USD&currencies=EUR,GBP,JPY

{
  "meta": { "last_updated_at": "2026-09-30T23:59:59Z" },
  "data": {
    "EUR": { "code": "EUR", "value": 0.8828 },
    "GBP": { "code": "GBP", "value": 0.7556 },
    "JPY": { "code": "JPY", "value": 157.98 }
  }
}

Keep reading

Get your free currency API key

Start with 300 free requests a month