otherguy / php-currency-api
A PHP API Wrapper to offer a unified programming interface for popular Currency Rate APIs.
Requires
- php: ^8.3
- ext-json: *
- brick/math: ^0.17
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.9
- http-interop/http-factory-guzzle: ^1.2
- laravel/pint: ^1.18
- php-coveralls/php-coveralls: ^2.7
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^12.0
- rector/rector: ^2.0
Suggests
- guzzlehttp/guzzle: PSR-18 HTTP client (^7.9), still the de-facto choice and what the test suite uses
- nyholm/psr7: Lightweight PSR-7/PSR-17 implementation, recommended with symfony/http-client
- symfony/http-client: Alternative PSR-18 client (^7.0); pair with nyholm/psr7 for PSR-17 factories
README
A PHP API Wrapper offering a unified, fluent programming interface for popular currency exchange rate APIs.
Don't worry about your favorite currency conversion service shutting down or changing plans. Switch providers without changing your code.
What's new in 2.0
- PHP 8.3+ with strict types everywhere.
- PSR-18 / PSR-17 HTTP layer — bring your own client (Guzzle, Symfony, anything PSR-compliant).
brick/mathBigDecimalfor precise rate math instead of floats.Currencybacked enum replaces the oldSymbolconstants class (which is kept as a deprecation shim).- New
frankfurterdriver — free, no API key required. - New
currencyapiandfastforexdrivers — provider parity with TripTally's backend FX stack. - Rewritten
exchangeratesapidriver — now points at the workingapi.apilayer.comendpoint with fullconvert()support. - Pluggable
DriverFactory— register your own provider at runtime.
You can find detailed instructions on how to upgrade from 1.x to 2.x in UPGRADING.md.
Features
- Multiple drivers behind a single interface — switch providers by changing one string.
- Fluent setter chain (
source,to,amount,date, …) on every driver. ConversionResultvalue object with lossless rebasing (setBaseCurrency()).- Hermetic test surface — inject any PSR-18 client, including in-memory mocks.
Supported APIs
| Service | Identifier |
|---|---|
| Frankfurter | frankfurter |
| FixerIO | fixerio |
| CurrencyLayer | currencylayer |
| Open Exchange Rates | openexchangerates |
| APILayer Exchange Rates | exchangeratesapi |
| CurrencyAPI | currencyapi |
| fastFOREX | fastforex |
A mock driver is also bundled for testing without network access.
Want another provider? Open an issue — or register a custom driver at runtime (see below).
Requirements
- PHP 8.3 or higher.
- A PSR-18 HTTP client and PSR-17 request factory of your choice.
- An API account with the chosen provider, except for
frankfurter.
Installation
composer require otherguy/php-currency-api
You also need a PSR-18 client and PSR-17 factory. The most common choice is Guzzle:
composer require guzzlehttp/guzzle http-interop/http-factory-guzzle
Alternatively, with Symfony HttpClient:
composer require symfony/http-client nyholm/psr7
Quickstart
use Otherguy\Currency\Currency; use Otherguy\Currency\DriverFactory; $result = DriverFactory::make('frankfurter') ->from(Currency::USD) ->to([Currency::EUR, Currency::GBP]) ->get(); echo $result->rate(Currency::EUR); // BigDecimal '0.92' echo $result->convert(100, Currency::USD, Currency::EUR); // BigDecimal '92.00'
DriverFactory::make() auto-discovers Guzzle if it's installed and wires up a default PSR-18 client. To inject your own:
use GuzzleHttp\Client; use Http\Factory\Guzzle\RequestFactory; use Otherguy\Currency\DriverFactory; $factory = new DriverFactory(); $driver = $factory->build('fixerio', new Client(), new RequestFactory()); $result = $driver->accessKey('YOUR_KEY') ->from(Currency::EUR) ->to(Currency::USD) ->get();
Bring your own HTTP client (Symfony + nyholm/psr7)
use Nyholm\Psr7\Factory\Psr17Factory; use Otherguy\Currency\DriverFactory; use Symfony\Component\HttpClient\Psr18Client; $psr17 = new Psr17Factory(); $client = new Psr18Client(); $driver = (new DriverFactory())->build('frankfurter', $client, $psr17);
Usage
The Currency enum
Otherguy\Currency\Currency is a backed enum with one case per ISO-4217 code (plus a few common crypto/precious-metal codes).
use Otherguy\Currency\Currency; Currency::USD->value; // 'USD' Currency::USD->displayName(); // 'United States Dollar' Currency::tryFromCode('EUR'); // Currency::EUR Currency::tryFromCode('XYZ'); // null Currency::cases(); // every supported currency
Every method that takes a currency accepts either the enum or its string code, so plain 'USD' keeps working.
Setting the access key
Most providers require authentication. accessKey() is sugar for config('access_key', …) and is wired per-driver to the right query-string parameter.
$driver->accessKey('YOUR_KEY');
Frankfurter has no API key — calling accessKey() on it throws ApiException. CurrencyAPI is the exception to the query-string rule: its driver sends the key in the apikey request header.
Provider-specific key mapping:
| Driver | accessKey() mapping |
|---|---|
fixerio |
access_key query parameter |
currencylayer |
access_key query parameter |
openexchangerates |
app_id query parameter |
exchangeratesapi |
apikey query parameter |
currencyapi |
apikey request header |
fastforex |
api_key query parameter |
frankfurter |
no key; throws ApiException |
Configuration options
For provider-specific options use config():
$driver->config('format', '1'); // CurrencyLayer pretty-printed JSON
Base currency
from() and source() are aliases.
$driver->from(Currency::USD); $driver->source('USD');
Each driver has its own default base currency: EUR for FixerIO, APILayer Exchange Rates, and Frankfurter; USD for CurrencyLayer, Open Exchange Rates, CurrencyAPI, fastFOREX, and the mock driver. Most providers only allow base-currency changes on paid plans — they'll respond with an error envelope which the driver translates into ApiException.
Target currencies
to() and currencies() are aliases. Pass a single currency or an array. Pass nothing (or an empty array) to ask for every currency the provider supports.
$driver->to(Currency::BTC); $driver->currencies([Currency::BTC, Currency::EUR, Currency::USD]); $driver->to([Currency::EUR, Currency::GBP]);
Latest rates
$driver->get(); // current rates for the configured target currencies $driver->get(Currency::DKK); // current rate for DKK
Historical rates
Dates must be DateTimeInterface (or null).
use DateTimeImmutable; $driver->date(new DateTimeImmutable('2010-01-01'))->historical(); $driver->historical(new DateTimeImmutable('2018-07-01'));
Convert amount
$driver->convert(10.00, Currency::USD, Currency::THB); $driver->convert(122.50, Currency::NPR, Currency::EUR, new DateTimeImmutable('2019-01-01'));
For providers without a native /convert endpoint (e.g. Frankfurter), the driver fetches the rate via get() / historical() and returns a ConversionResult for the requested pair. Use ConversionResult::convert() when you need the converted amount as a BigDecimal.
CurrencyAPI and fastFOREX both expose native latest conversion endpoints. For dated conversions, their drivers fetch historical rates and return a ConversionResult for the requested pair.
Fluent chain
DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::EUR)->get(); DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::NPR)->date(new DateTimeImmutable('2013-03-02'))->historical(); DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::NPR)->amount(12.10)->convert();
ConversionResult
get() and historical() return a ConversionResult. Rates are stored as BigDecimal and rebasing is lossless (default scale: 8 decimals).
use Brick\Math\BigDecimal; $result = DriverFactory::make('frankfurter') ->from(Currency::USD) ->to([Currency::EUR, Currency::GBP]) ->get(); $result->all(); // ['USD' => BigDecimal '1', 'EUR' => BigDecimal '0.89', 'GBP' => BigDecimal '0.79'] $result->allAsFloats(); // legacy float view $result->getBaseCurrency(); // 'USD' $result->getDate(); // '2026-04-25' $result->rate(Currency::EUR); // BigDecimal '0.89' $result->rateAsFloat(Currency::EUR);// 0.89 $result->convert(5.0, Currency::EUR, Currency::USD); // BigDecimal '5.618...' $rebased = $result->setBaseCurrency(Currency::EUR); $rebased->getBaseCurrency(); // 'EUR' $rebased->originalBaseCurrency; // 'USD' — readonly, never mutated
rate() on a code that wasn't fetched throws Otherguy\Currency\Exceptions\CurrencyException. To convert between two arbitrary currencies, request both in the original get() / historical() call.
Registering custom drivers
The factory is instance-based. Bring your own driver class (extending BaseCurrencyDriver) and register it:
use Otherguy\Currency\DriverFactory; $factory = new DriverFactory(); $factory->register('mybank', \Acme\MyBankDriver::class); $driver = $factory->build('mybank');
The static DriverFactory::make($name) continues to work via a process-wide default factory — DriverFactory::setDefault($factory) lets you swap it for tests.
Adding a new driver
A driver is the bridge between this library's fluent interface and a specific upstream rate provider. Every driver implements CurrencyDriverContract by extending BaseCurrencyDriver.
The base class supplies:
- All fluent setters (
source,from,currencies,to,amount,date,config,accessKey,secure). - A PSR-18 / PSR-17 HTTP layer in
apiRequest()that builds the URI, merges$httpParamswith per-call params, decodes JSON withJSON_THROW_ON_ERROR, and wraps every failure mode inApiException.
You only need to:
- Set the right defaults for
$apiURL,$protocol, and$baseCurrency. - Implement
get(),historical(), andconvert(). - Override
apiRequest()only if the provider's successful HTTP response can still carry an error envelope, such assuccess: false.
Driver skeleton
<?php declare(strict_types=1); namespace Otherguy\Currency\Drivers; use Brick\Math\BigDecimal; use Brick\Math\RoundingMode; use DateTimeInterface; use Otherguy\Currency\Currency; use Otherguy\Currency\Exceptions\ApiException; use Otherguy\Currency\Results\ConversionResult; use Override; class MyProvider extends BaseCurrencyDriver { protected string $apiURL = 'api.myprovider.example/v1'; protected string $protocol = 'https'; protected string $baseCurrency = 'USD'; #[Override] public function get(string|Currency|array $forCurrency = []): ConversionResult { if ($forCurrency !== []) { $this->currencies($forCurrency); } $response = $this->apiRequest('latest', [ 'base' => $this->getBaseCurrency(), 'symbols' => implode(',', $this->getSymbols()), ]); return new ConversionResult( (string) $response['base'], (string) $response['date'], $response['rates'], ); } #[Override] public function historical( ?DateTimeInterface $date = null, string|Currency|array $forCurrency = [], ): ConversionResult { if ($date instanceof DateTimeInterface) { $this->date($date); } if ($forCurrency !== []) { $this->currencies($forCurrency); } if ($this->getDate() === null) { throw new ApiException('Date is required for historical().'); } $response = $this->apiRequest('history/' . $this->getDate(), [ 'base' => $this->getBaseCurrency(), ]); return new ConversionResult( (string) $response['base'], (string) $response['date'], $response['rates'], ); } #[Override] public function convert( ?float $amount = null, string|Currency|null $fromCurrency = null, string|Currency|null $toCurrency = null, ?DateTimeInterface $date = null, ): ConversionResult { if ($amount !== null) { $this->amount = $amount; } if ($fromCurrency !== null) { $this->source($fromCurrency); } if ($toCurrency !== null) { $this->to($toCurrency); } if ($date instanceof DateTimeInterface) { $this->date($date); } if ($this->amount === null) { throw new ApiException('An amount is required for convert().'); } if ($this->currencies === []) { throw new ApiException('A target currency is required for convert().'); } $target = $this->getSymbols()[0]; $response = $this->apiRequest('convert', [ 'from' => $this->getBaseCurrency(), 'to' => $target, 'amount' => $this->amount, ]); $rate = BigDecimal::of((string) $response['result']) ->dividedBy(BigDecimal::of((string) $this->amount), ConversionResult::DEFAULT_SCALE, RoundingMode::HalfUp); return new ConversionResult( $this->getBaseCurrency(), isset($response['date']) ? (string) $response['date'] : null, [$target => $rate], ); } }
For providers without a native conversion endpoint, fetch rates through get() / historical() and return the resulting ConversionResult; Frankfurter is the compact example.
Driver authentication
accessKey() defaults to writing access_key=... into $httpParams. If your provider uses a different parameter name, override it:
#[\Override] public function accessKey(string $accessKey): static { return $this->config('apikey', $accessKey); }
For header authentication, write to $httpHeaders:
#[\Override] public function accessKey(string $accessKey): static { $this->httpHeaders['apikey'] = $accessKey; return $this; }
If the provider has no keys, throw to make misuse loud:
#[\Override] public function accessKey(string $accessKey): static { throw new ApiException('MyProvider does not require an API key.'); }
Provider-specific error envelopes
Many providers return HTTP 200 with an error body. Override apiRequest() to translate those into ApiException before the value reaches get() / historical() / convert():
#[\Override] protected function apiRequest(string $endpoint, array $params = []): array { $response = parent::apiRequest($endpoint, $params); if (($response['success'] ?? null) !== true) { $info = (string) ($response['error']['info'] ?? 'Unknown API error.'); throw new ApiException($info); } return $response; }
First-party driver registration
For first-party drivers shipped with this package, add the class to the built-in map in DriverFactory:
public function __construct(?array $drivers = null) { $this->drivers = $drivers ?? [ // ... 'myprovider' => MyProvider::class, ]; }
For third-party drivers, use runtime registration as shown above. register() returns $this, unregister(string $name) removes a driver, and build() accepts optional PSR-18 + PSR-17 collaborators. If those collaborators are omitted, the factory tries to auto-discover Guzzle.
Driver tests
Driver tests live under tests/Drivers/. Use tests/Support/DriverHarness.php to wire up an in-process PSR-18 mock:
use Otherguy\Currency\Currency; use Otherguy\Currency\Tests\Support\DriverHarness; use Otherguy\Currency\Tests\Support\JsonResponse; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class MyProviderTest extends TestCase { private DriverHarness $harness; protected function setUp(): void { $this->harness = new DriverHarness(); } #[Test] public function get_parses_provider_envelope(): void { $this->harness->http->enqueue(JsonResponse::ok(json_encode([ 'base' => 'USD', 'date' => '2026-04-01', 'rates' => ['EUR' => 0.92], ], JSON_THROW_ON_ERROR))); $result = $this->harness->make('myprovider') ->accessKey('test-key') ->from(Currency::USD) ->to(Currency::EUR) ->get(); $this->assertSame('0.92', (string) $result->rate(Currency::EUR)); $request = $this->harness->http->lastRequest(); $this->assertNotNull($request); $this->assertStringContainsString('apikey=test-key', $request->getUri()->getQuery()); } }
DriverHarness instantiates a fresh MockHttpClient on each test. Use enqueue() to queue responses, lastRequest() to assert URI/query/headers, and sentRequests() for multi-request flows.
Driver checklist
-
declare(strict_types=1)andOverrideattributes where you override. -
$apiURLdoes not include thehttps://prefix;BaseCurrencyDriveradds the protocol. -
get(),historical(), andconvert()returnConversionResult, not arrays. - Error envelopes are wrapped in
ApiExceptionso callers see one consistent failure type. - PHPStan is clean at
level: max(composer analyse). - Tests cover happy path, error envelope, and any
accessKey()quirks. - First-party drivers are registered in
DriverFactoryand listed in the Supported APIs table.
For real examples, browse the existing drivers. They range from thin happy-path code in Frankfurter, to header authentication in CurrencyApi, to envelope translation in FixerIo, CurrencyLayer, and ExchangeRatesApi.
Testing
The library exposes Otherguy\Currency\Drivers\MockCurrencyDriver for consumers writing tests without a network. Seed it with rates and use it like any other driver:
use Otherguy\Currency\Drivers\MockCurrencyDriver; use Otherguy\Currency\DriverFactory; $driver = DriverFactory::make('mock'); assert($driver instanceof MockCurrencyDriver); $driver->withRates(['EUR' => '0.92', 'GBP' => '0.79']); $driver->get()->rate('EUR'); // BigDecimal '0.92'
For testing this library itself, see tests/Support/MockHttpClient.php — a tiny in-process PSR-18 double that records sent requests and replays queued responses. CONTRIBUTING.md walks through it.
Project commands
| Command | What it does |
|---|---|
composer test |
Run the test suite |
composer test:coverage |
Run with coverage (requires Xdebug) |
composer lint |
Pint code-style check (read-only) |
composer lint:fix |
Pint auto-fix |
composer analyse |
PHPStan at level max |
composer rector |
Rector dry-run |
composer rector:fix |
Rector apply |
composer check |
All of the above, in order |
Contributing
Pull requests are welcome — please run composer check before opening one. Coverage target is ≥ 98% on src/. See CONTRIBUTING.md for the full guide.
License
MIT.
