peterlupu / curs-bnr
Official exchange rates from the National Bank of Romania (BNR / curs valutar BNR) XML feeds, with Fiscal Code art. 290 'valid on' vs 'communicated on' semantics. Framework-agnostic, zero dependencies.
Requires
- php: ^8.1
- ext-libxml: *
- ext-simplexml: *
Requires (Dev)
- laravel/pint: ^1.13
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Suggests
- psr/http-client: To use Psr18HttpClient with any PSR-18 client (e.g. Guzzle) instead of the built-in native client
- psr/http-factory: Required alongside psr/http-client to build requests for Psr18HttpClient
README
Official BNR exchange rates for PHP โ with the fiscal semantics done right.
curs-bnr fetches the official exchange rates of the National Bank of Romania (cursul valutar BNR) and answers the question every Romanian invoicing, accounting, or pricing app eventually asks: "which rate applies on this date?" โ exactly as the Fiscal Code defines it.
- ๐ชถ Zero dependencies โ just PHP โฅ 8.1 with
ext-simplexml. No PSR plumbing, no HTTP discovery magic. - ๐งพ Fiscal Code art. 290 semantics built in โ
validOn()vscommunicatedOn(), so you never writesubDay()+ walk-back loops again. - ๐ Historical rates back to 2005, weekends, legal holidays, and year boundaries handled for you.
- ๐ Transient-failure retries with configurable backoff.
- ๐งช Fully unit-tested against real BNR documents โ both the old and the new XML namespace (yes, BNR changed it in 2026 and broke half the ecosystem).
- ๐งฉ Framework-agnostic โ a plain PHP class you can use anywhere and wire into any container.
- ๐ญ Batteries-included testing โ an
ExchangeRatesinterface plus a shippedFakeCursBnrthat runs the same semantics code as the real client, so your tests never touch the network.
๐ฆ Installation
composer require peterlupu/curs-bnr
๐ Quick start
use CursBnr\CursBnr; $bnr = new CursBnr(); // The latest communicated rate $rate = $bnr->latest('EUR'); $rate->value; // 5.246 (RON per 1 EUR) $rate->communicatedOnYmd(); // '2026-08-04' // The rate legally valid on a given date (this is the one you invoice with!) $rate = $bnr->validOn('EUR', new DateTimeImmutable('2024-01-27')); $rate->value; // 4.9763 โ communicated Friday Jan 26 // Convert between any two currencies $factor = $bnr->pair('EUR', 'RON', new DateTimeImmutable('2024-01-27')); $amountInRon = $amountInEur * $factor;
That's it. No API keys, no configuration โ BNR's feeds are public.
๐งพ "Which rate applies today?" โ the D vs D+1 rule
This is the part every BNR consumer gets subtly wrong, so it deserves its own section.
BNR communicates rates each banking day around 13:00 Romanian time. Per Fiscal Code art. 290, the rate communicated on day D is valid on day D+1 โ and it stays valid through any weekend or legal holiday that follows, until the next communication.
A real example, January 2024:
| Date | What happened | Valid rate (EUR) |
|---|---|---|
| Thu 25 | BNR communicates 4.9765 | the rate from Wed 24โฆ |
| Fri 26 | BNR communicates 4.9763 | 4.9765 (Thursday's) |
| Sat 27 | nothing communicated | 4.9763 (Friday's) |
| Sun 28 | nothing communicated | 4.9763 (Friday's) |
| Mon 29 | BNR communicates again | 4.9763 (still Friday's) |
Pick your method by the question you're asking:
| You're askingโฆ | Use | Weekend/holiday behavior |
|---|---|---|
| Which rate is legally valid on this date? (invoices, VAT, contracts) | validOn($currency, $date) |
walks back to the last communication before the date โ |
| What did BNR communicate on this exact day? | communicatedOn($currency, $date) |
throws NoRateCommunicatedException ๐ซ |
| What's the most recent rate right now? | latest($currency) |
n/a โ reads the daily feed |
๐ก If you were previously doing
$date->subDay()and then looping backwards until a rate showed up โvalidOn()is that, done correctly, including holiday runs and year boundaries.
๐ถ Rates, multipliers, and the Rate object
Every lookup returns a CursBnr\Rate:
$rate = $bnr->validOn('HUF', new DateTimeImmutable('2024-01-27')); $rate->currency; // 'HUF' $rate->value; // 0.013620 โ RON per 1 HUF (multiplier already applied!) $rate->multiplier; // 100 โ BNR quotes HUF per 100 units; we normalize for you $rate->communicatedAt; // DateTimeImmutable of the communication date $rate->validFrom(); // communicatedAt + 1 day, per art. 290
Notes worth knowing:
- ๐ญ๐บ Multiplier currencies (quoted per 100 units โ currently HUF, IDR, ISK, JPY, KRW; read from the feed, not hardcoded) are normalized to per-1-unit values. You never touch the multiplier unless you want to display it.
- ๐ฅ XAU is RON per gram of gold, as published by BNR.
- ๐ท๐ด RON itself is never in the feed (everything is quoted against it), so
latest('RON')throws. For RON conversions usepair()โ read on.
๐ Currency pairs and cross rates
pair($from, $to, $validOn = null) returns the factor that converts an amount of $from into $to:
$bnr->pair('EUR', 'RON'); // 4.9763 โ the rate valid TODAY $bnr->pair('RON', 'EUR'); // 0.20095... โ full precision, no rounding $bnr->pair('EUR', 'USD'); // cross rate via RON $bnr->pair('EUR', 'EUR'); // 1.0 โ no HTTP request at all $bnr->pair('EUR', 'RON', new DateTimeImmutable('2024-01-27')); // valid-on-date semantics
- With no date, it uses the rates valid today โ exactly the same as passing today's date. (If you want the freshly communicated rate that becomes valid tomorrow, use
latest().) - With a date, both legs use the rate valid on that date (same walk-back as
validOn()). - โ ๏ธ Inverse and cross rates are returned at full float precision. (florianv/swap used to truncate inverses to 4 decimals โ we don't. Round at display time, not in your data.)
๐ A whole day of rates in one call
Updating a rates table nightly? Don't make 30 requests:
$day = $bnr->latestDay(); // one HTTP call $day->rate('EUR')->value; $day->rate('USD')->value; $day->rate('CHF')->value; $day->communicatedAt; // when this Cube was communicated $day->validFrom(); // the day these rates become valid $day->has('MDL'); // true foreach ($day->rates as $code => $rate) { /* all ~30 of them */ }
The same works for any date โ one lookup, every currency:
$bnr->validDayOn(new DateTimeImmutable('2024-01-27')); // rates valid on that day $bnr->communicatedDayOn(new DateTimeImmutable('2024-01-26')); // rates communicated that exact day
๐ฐ๏ธ Dates, timezones, and other sharp edges
- Any
DateTimeInterfaceis accepted โDateTime,DateTimeImmutable, Carbon, CarbonImmutable โ and is reduced to its calendar date (Y-m-d). No timezone conversion is performed: if you pass "2024-01-27" in any timezone, you get the rate for January 27. - ๐ Near midnight this matters:
new DateTime('now')on a UTC server at 23:30 Romanian time is still "yesterday" in UTC. If the exact day is important, build your dates inEurope/Bucharest. - ๐ Historical data starts at 2005 (that's as far back as BNR's yearly XML files go). Earlier dates throw
UnsupportedDateException. - ๐ฎ Future dates are refused, not guessed. The rate valid on a future day depends on a communication BNR hasn't made yet โ silently answering with today's rate would give you a number that changes once BNR publishes. Anything beyond tomorrow throws
RateNotYetAvailableException; tomorrow itself is answered as soon as today's ~13:00 communication is out. - ๐ท
validOn()gives up after 15 days of walking back (configurable) โ with real BNR data that never happens; if it does, the feed is broken and you'll get aWalkBackExhaustedExceptioninstead of an infinite loop.
๐จ Error handling
Every runtime failure extends one base class (programmer errors โ a negative retry count, a malformed date string โ throw plain SPL \InvalidArgumentException/\LogicException, as usual):
use CursBnr\Exception\CursBnrException; try { $rate = $bnr->validOn('EUR', $date); } catch (CursBnrException $e) { // network down, feed broken, unknown currency, ... โ one catch to rule them all }
Or catch precisely:
| Exception | When | Useful properties |
|---|---|---|
TransportException |
HTTP failed (after retries) | $url, $attempts |
ParseException |
feed returned something that isn't a valid BNR document (incl. non-numeric rate values) | $url |
UnknownCurrencyException |
currency not quoted by BNR (incl. RON in single-currency calls) |
$currency |
NoRateCommunicatedException |
communicatedOn() hit a weekend/holiday โ or today, before ~13:00 |
$date |
RateNotYetAvailableException |
dated lookup for a day whose communication hasn't happened yet | $date, $today |
WalkBackExhaustedException |
validOn() found nothing within the bound |
$date, $daysTried |
UnsupportedDateException |
date (or walk-back) before 2005 | $date |
All exception properties are readonly โ safe to log, impossible to mangle.
๐ง Advanced configuration
Everything is constructor-injected and optional:
use CursBnr\CursBnr; use CursBnr\FeedUrls; use CursBnr\Http\RetryPolicy; $bnr = new CursBnr( retryPolicy: new RetryPolicy(attempts: 5, backoffMs: [100, 500, 2000]), maxWalkBackDays: 30, );
Retries ๐
Transient HTTP failures are retried automatically (default: 3 attempts, 200ms/400ms backoff). Tune it, give it a closure, or turn it off:
new RetryPolicy(attempts: 4, backoffMs: fn (int $retry) => $retry * 250); RetryPolicy::none(); // fail fast
Only transport errors are retried โ a malformed document will never be re-fetched in a loop.
The clock ๐
The future-date guard needs to know what "today" is (Europe/Bucharest by default). For deterministic tests of code that uses the real client, inject one:
$bnr = new CursBnr(clock: fn () => new DateTimeImmutable('2024-01-29'));
(For application tests you'll usually reach for FakeCursBnr instead โ see below.)
Bring your own HTTP client ๐
The default client is built on PHP streams and needs nothing installed. If you'd rather route through your existing stack, implement the one-method interface:
use CursBnr\Http\HttpClientInterface; final class MyClient implements HttpClientInterface { public function get(string $url): string { /* ... */ } } $bnr = new CursBnr(httpClient: new MyClient());
โฆor adapt any PSR-18 client (Guzzle shown; requires psr/http-client + a PSR-17 factory, which you likely already have):
use CursBnr\Http\Psr18HttpClient; $bnr = new CursBnr(httpClient: new Psr18HttpClient( new \GuzzleHttp\Client(), new \GuzzleHttp\Psr7\HttpFactory(), ));
Custom feed URLs ๐งญ
Point at a mirror, a proxy, or a test server:
$bnr = new CursBnr(urls: new FeedUrls( daily: 'https://my-mirror.example/nbrfxrates.xml', yearlyTemplate: 'https://my-mirror.example/years/nbrfxrates{year}.xml', ));
๐๏ธ Caching (there isn't any โ on purpose)
The package performs no caching: a rate for a past date never changes, but your app knows best how long to remember it and where. The only smartness inside is per-instance memoization of already-parsed yearly documents, so one walk-back over a weekend doesn't re-download the same file three times.
Wrapping it is a one-liner with whatever cache you already have (PSR-16 shown):
$key = "bnr:EUR:RON:{$date->format('Y-m-d')}"; $value = $cache->get($key); if ($value === null) { $value = $bnr->pair('EUR', 'RON', $date); $cache->set($key, $value, 600); }
In a framework, register CursBnr\CursBnr as a singleton in your DI container and inject it โ there's no service provider or bundle to install, and none is needed.
๐งฌ Data source & reliability notes
- Feeds:
curs.bnr.ro/nbrfxrates.xml(daily) andcurs.bnr.ro/files/xml/years/nbrfxrates{year}.xml(per year, 2005+), the official endpoints per BNR's documentation. - The XML namespace is detected from the document, not hardcoded. BNR changed it from
http://www.bnr.ro/xsdtohttps://www.bnr.ro/xsdin 2026 โ silently breaking every parser that had it hardcoded (this package exists partly because of that day ๐). Both variants are covered by tests. - BOMs, redirects, and multiplier quirks are handled; garbage responses fail loudly with
ParseException.
๐งช Testing your app
The package ships everything you need to test code that depends on exchange rates โ no HTTP, no XML fixtures, no mocking library.
Step 1: depend on the interface
Type-hint CursBnr\ExchangeRates (not the concrete class) and bind the real client in your DI container:
use CursBnr\CursBnr; use CursBnr\ExchangeRates; final class InvoiceTotals { public function __construct(private readonly ExchangeRates $bnr) {} public function inRon(float $amountEur, \DateTimeInterface $issuedOn): float { return $amountEur * $this->bnr->pair('EUR', 'RON', $issuedOn); } } // production wiring: bind ExchangeRates => new CursBnr()
Step 2: use the built-in fake in tests
CursBnr\Testing\FakeCursBnr is an in-memory ExchangeRates implementation. For most tests, constant mode is all you need โ one timeless answer for any date:
use CursBnr\Testing\FakeCursBnr; $bnr = FakeCursBnr::constant(['EUR' => 5.0, 'USD' => 4.5]); $totals = new InvoiceTotals($bnr); $totals->inRon(100.0, new DateTimeImmutable('2024-01-27')); // 500.0 โ always
Step 3: go date-aware when the calendar matters
Register specific communications and the fake behaves exactly like production โ strictly-before validOn(), weekend/holiday walk-back, the works:
$bnr = (new FakeCursBnr()) ->communicate('2024-01-25', ['EUR' => 4.9765]) ->communicate('2024-01-26', ['EUR' => 4.9763]); // nothing on 27-28 โ the weekend $bnr->validOn('EUR', new DateTimeImmutable('2024-01-26'))->value; // 4.9765 โ Thursday's (strictly before!) $bnr->validOn('EUR', new DateTimeImmutable('2024-01-27'))->value; // 4.9763 โ Friday's $bnr->latest('EUR')->value; // 4.9763 โ most recent registration
Step 4: test your error handling too
The fake throws the same typed exceptions as the real client, so your failure paths get real coverage instead of hand-waved mocks:
$bnr->communicatedOn('EUR', new DateTimeImmutable('2024-01-27')); // NoRateCommunicatedException (weekend) $bnr->latest('CHF'); // UnknownCurrencyException (not registered) FakeCursBnr::constant(['EUR' => 5.0], maxWalkBackDays: 3); // bound configurable, like the real thing // pin "today" to make the future-date guard deterministic in tests new FakeCursBnr(today: '2024-01-29');
๐ฌ The fake cannot drift from the real client: both run the exact same internal semantics code (walk-back, future-date guard, 2005 floor) โ and a parity test runs a full scenario through both anyway, asserting identical values and identical exceptions.
Testing the wiring itself
If you specifically want to exercise the real parser/HTTP pipeline (rarely needed), the lower-level seam is still there: implement the one-method HttpClientInterface returning canned XML and point FeedUrls at test URLs.
๐งฉ Wiring it into your framework
For plain unit tests you don't need a container at all โ constructor-inject ExchangeRates and pass the fake directly:
$service = new InvoiceTotals(FakeCursBnr::constant(['EUR' => 5.0]));
The container binding matters for production wiring and for feature/integration tests, where your framework constructs the objects. Recipes:
Laravel
// app/Providers/AppServiceProvider.php public function register(): void { $this->app->singleton(\CursBnr\ExchangeRates::class, \CursBnr\CursBnr::class); }
// in a feature test โ every resolved service now sees the fake $this->app->instance( \CursBnr\ExchangeRates::class, \CursBnr\Testing\FakeCursBnr::constant(['EUR' => 5.0]), );
Symfony
# config/services.yaml services: CursBnr\CursBnr: ~ CursBnr\ExchangeRates: '@CursBnr\CursBnr'
// in a KernelTestCase / WebTestCase static::getContainer()->set( \CursBnr\ExchangeRates::class, \CursBnr\Testing\FakeCursBnr::constant(['EUR' => 5.0]), );
Any PSR-11 container (PHP-DI, League, ...)
Alias CursBnr\ExchangeRates to a shared CursBnr\CursBnr instance; in tests, register FakeCursBnr under the same id before booting the code under test.
// PHP-DI example return [ CursBnr\ExchangeRates::class => DI\autowire(CursBnr\CursBnr::class), ];
โ๏ธ Whatever the framework: depend on
ExchangeRates, never onCursBnrdirectly, and the production/test split stays a one-line binding.
๐ Versioning & backward compatibility
The package follows semver. The public API is everything
under CursBnr\ except classes and methods marked @internal โ those may
change in any release. While on 0.x, breaking changes may land in minor
versions and are always called out in the CHANGELOG; from
1.0 on, they only happen in major versions.
Security issues: see SECURITY.md โ please report privately.
๐ค Development & AI transparency
This package is developed with AI assistance (Claude Code) โ
disclosure level ai-generated per the ai-disclosure
convention; details in AI_DISCLOSURE.md. Every release is
human-reviewed and gated by the full test suite (run against real BNR XML
documents), PHPStan at level max, code style checks, and CI across all
supported PHP versions. Issues and PRs are read by a human โ me.
๐ License
MIT ยฉ Peter Lupu