Skip to content
PHP guide

Exchange rate API in PHP: cURL, Guzzle and Laravel

Fetch live and historical currency rates in plain PHP, wrap them in a Guzzle client, and build a cached Laravel service – with proper handling for 401, 403, 422 and 429.

Last updated: · fxapi team

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

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?
No. The cURL extension that ships with most PHP builds is enough. Guzzle or Laravel’s HTTP client add nicer error handling, middleware and testing helpers.
How long should Laravel cache exchange rates?
As long as your plan’s update interval: 86,400 seconds on Free, 3,600 on Basic and 60 on Professional or Enterprise. Historical rates never change, so cache them with Cache::rememberForever().
How do I convert currency in PHP without the convert endpoint?
Request /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?
Prefer the apikey header. Query parameters tend to end up in web server logs, proxy logs and error trackers.
Is there an official PHP package?
Yes, everapi/fxapi-php on Packagist. It is a thin Guzzle-based wrapper that returns decoded arrays.
Free plan · no credit card

Get your free exchange rate API key

300 requests a month, latest and historical rates, fluctuation and averages – free forever. Upgrade when you need faster updates or more requests.