This guide shows how to use an exchange rate API in PHP: plain cURL for scripts and legacy code, Guzzle for modern applications, and a Laravel service that reads the key from config and caches rates with the Cache facade. Each example fetches latest rates, a historical rate and a conversion, and handles the API’s error codes.
Prerequisites
- PHP 8.1+ with the cURL extension
- An fxapi key from the free sign-up, stored in the environment as
FXAPI_KEY
The base URL is https://api.fxapi.com/v1. Send the key in the apikey header; the query parameter variant works too but tends to end up in logs.
Exchange rate API in PHP with cURL
<?php
final class FxApiException extends RuntimeException
{
public function __construct(public readonly int $status, string $message)
{
parent::__construct($message, $status);
}
}
function fxapi_get(string $path, array $params = []): array
{
$ch = curl_init('https://api.fxapi.com/v1' . $path . '?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['apikey: ' . getenv('FXAPI_KEY'), 'Accept: application/json'],
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException('Network error: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$json = json_decode($body, true) ?? [];
if ($status === 200) {
return $json;
}
throw new FxApiException($status, match ($status) {
401 => 'Invalid API key',
403 => 'Endpoint or option not included in your plan',
422 => 'Validation error: ' . json_encode($json['errors'] ?? []),
429 => 'Rate limit or monthly quota reached',
default => "Unexpected HTTP status $status",
});
}
// Latest rates
$latest = fxapi_get('/latest', ['base_currency' => 'EUR', 'currencies' => 'USD,GBP,CHF']);
echo $latest['meta']['last_updated_at'], ' ', $latest['data']['USD']['value'], PHP_EOL;
// Historical rate (end of day, UTC)
$hist = fxapi_get('/historical', ['date' => '2024-12-31', 'base_currency' => 'EUR', 'currencies' => 'USD']);
echo $hist['data']['USD']['value'], PHP_EOL;
A 422 response has an errors object keyed by parameter name – for example when date is in the future or before 1999-01-01. Validation and server errors don’t count against your quota.
Currency conversion in PHP
With the Basic plan or higher, /v1/convert returns converted amounts:
$conv = fxapi_get('/convert', ['value' => 250, 'base_currency' => 'USD', 'currencies' => 'EUR,GBP']);
echo $conv['data']['EUR']['value'];
On the Free plan, compute it from the latest rates:
$rates = fxapi_get('/latest', ['base_currency' => 'USD', 'currencies' => 'EUR'])['data'];
$eur = round(250 * $rates['EUR']['value'], 2, PHP_ROUND_HALF_EVEN);
round() on floats is fine for display. For invoices and accounting, use bcmath or a money library and the currency’s decimal_digits – see currency rounding and precision.
Using Guzzle
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
$fx = new Client([
'base_uri' => 'https://api.fxapi.com/v1/',
'headers' => ['apikey' => getenv('FXAPI_KEY')],
'timeout' => 10,
]);
try {
$res = $fx->get('latest', ['query' => ['base_currency' => 'USD']]); // all 190+ currencies
$latest = json_decode((string) $res->getBody(), true);
echo 'Quota left: ', $res->getHeaderLine('X-RateLimit-Remaining-Quota-Month'), PHP_EOL;
} catch (ClientException $e) {
$status = $e->getResponse()->getStatusCode();
$body = json_decode((string) $e->getResponse()->getBody(), true);
if ($status === 422) {
error_log('Invalid parameters: ' . json_encode($body['errors'] ?? []));
} elseif ($status === 429) {
// Monthly quota or (Free plan) 10 requests/minute reached: use cached rates
}
throw $e;
}
Install with composer require guzzlehttp/guzzle. Note the trailing slash on base_uri and no leading slash on latest – that’s how Guzzle resolves relative paths.
Laravel: config, HTTP client and Cache facade
Add the key to config/services.php:
'fxapi' => [
'key' => env('FXAPI_KEY'),
'ttl' => (int) env('FXAPI_TTL', 3600), // seconds; match your plan's update interval
],
Then create a service class. Laravel’s Http client is built on Guzzle:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ExchangeRates
{
public function latest(string $base = 'USD'): array
{
$key = "fxapi:latest:{$base}";
try {
return Cache::remember($key, config('services.fxapi.ttl'), function () use ($base, $key) {
$data = $this->request('latest', ['base_currency' => $base]);
Cache::forever("{$key}:last-good", $data);
return $data;
});
} catch (\Throwable $e) {
report($e);
return Cache::get("{$key}:last-good") ?? throw $e; // serve stale on failure
}
}
public function historical(string $date, string $base = 'USD'): array
{
return Cache::rememberForever(
"fxapi:historical:{$base}:{$date}",
fn () => $this->request('historical', ['date' => $date, 'base_currency' => $base])
);
}
public function convert(float $amount, string $from, string $to): float
{
$rate = $this->latest($from)['data'][$to]['value'];
return round($amount * $rate, 2, PHP_ROUND_HALF_EVEN);
}
private function request(string $endpoint, array $query): array
{
$response = Http::withHeaders(['apikey' => config('services.fxapi.key')])
->timeout(10)
->get("https://api.fxapi.com/v1/{$endpoint}", $query);
if ($response->status() === 422) {
throw new \InvalidArgumentException(json_encode($response->json('errors')));
}
$response->throw(); // 401, 403, 429 and 5xx become RequestException
return $response->json();
}
}
Use it anywhere via dependency injection:
public function show(ExchangeRates $fx)
{
return ['eur' => $fx->convert(49.00, 'USD', 'EUR')];
}
Because latest() requests all currencies for a base, a single API call per TTL serves every page and every currency pair. With a shared cache store (Redis, Memcached, database), all workers and servers share it too. More patterns – stale-while-revalidate, storing the rate with each transaction – are in caching exchange rates.
Testing with Http::fake
Laravel’s HTTP client can be faked, so tests never hit the network or your quota:
use Illuminate\Support\Facades\Http;
Http::fake([
'api.fxapi.com/v1/latest*' => Http::response([
'meta' => ['last_updated_at' => '2026-10-01T23:59:59Z'],
'data' => ['EUR' => ['code' => 'EUR', 'value' => 0.9]],
]),
]);
$this->assertSame(9.0, app(ExchangeRates::class)->convert(10.0, 'USD', 'EUR'));
Swap in Http::response([], 429) to test the stale fallback. On Professional and Enterprise you can also use sandbox keys for integration tests: they return random dummy values and X-Cost: 0.
Using the official PHP package
everapi/fxapi-php wraps each endpoint in a method that takes an array of query parameters and returns the decoded JSON as an array:
composer require everapi/fxapi-php
<?php
$fxApi = new \FxApi\FxApi\FxApiClient(getenv('FXAPI_KEY'));
$latest = $fxApi->latest(['base_currency' => 'EUR', 'currencies' => 'USD']);
$hist = $fxApi->historical(['date' => '2024-12-31', 'currencies' => 'USD']);
The client doesn’t throw on HTTP error statuses, so check the returned array (for example for an errors key) before using data.
Next steps
- Endpoint reference: latest, historical, convert, currencies.
- Compare plans and update intervals on the pricing page.
- Building subscriptions in several currencies? See SaaS billing and invoicing.
The examples in this guide use fxapi, a foreign exchange rate API with a free plan of 300 requests a month – no credit card required.
Frequently asked questions
Do I need Guzzle to call the exchange rate API from PHP?
How long should Laravel cache exchange rates?
Cache::rememberForever().How do I convert currency in PHP without the convert endpoint?
/v1/latest with base_currency set to the source currency and multiply by the target rate. /v1/convert is available from the Basic plan.Should I put the API key in the query string?
apikey header. Query parameters tend to end up in web server logs, proxy logs and error trackers.Is there an official PHP package?
everapi/fxapi-php on Packagist. It is a thin Guzzle-based wrapper that returns decoded arrays.