redcode/currency-rate-bundle
RedCode CurrencyRateBundle provides currency exchange rate retrieval and management for PHP/Symfony applications, with configurable providers and caching to support reliable conversions and up-to-date rate data.
First, calibrate your expectation. This is a Symfony 2.x bundle, not a framework-agnostic library. There is no Laravel service provider, no facade, no auto-discovery — composer require redcode/currency-rate-bundle into a Laravel app gets you classes and a DependencyInjection/ folder that will never boot. Check composer.json before anything else: the symfony/framework-bundle constraint tells you whether you're dealing with a package you can use or one you can only read.
For a Laravel codebase the realistic path is: install it as a reference implementation, then lift the two things that carry all the value — the CBR and ECB XML parsers.
Read in this order:
Provider/CbrProvider.php and Provider/EcbProvider.php — the actual network + XML parsing logic. This is 80% of the package.Currency/Rate.php (or the equivalent rate/convert service) — the conversion arithmetic and how base currency is normalised.Resources/config/services.yml — the service IDs (redcode_currency_rate.rate etc.) if you need to mirror the wiring in a Laravel service provider.Command/ — if present, the update command shows the intended refresh cadence.First use case: converting a product price from USD/EUR into RUB for display, using the CBR daily feed. The naive version, no bundle wiring:
$provider = new \RedCode\CurrencyRateBundle\Provider\CbrProvider();
$rates = $provider->getRates(); // code => rate, RUB-based
$rub = 100 * $rates['USD']; // 100 USD in RUB
If you do have a legacy Symfony app in the stack, the bundle-native route is config-only:
# app/config/config.yml
redcode_currency_rate:
provider: cbr
$rate = $container->get('redcode_currency_rate.rate');
echo $rate->convert(100, 'USD', 'RUB');
That's the whole onboarding surface. Everything past this point is you building the parts a Laravel app expects and this bundle doesn't provide.
Ingest on a schedule, convert on read. Don't call the provider inside a request. Write an Artisan command that pulls the feed once a day and persists a row per (provider, base, quote, rate_date). CBR publishes daily around 11:30 MSK; ECB publishes once per TARGET business day around 16:00 CET. One command, one ->dailyAt('12:30') entry, and your request path never touches the network.
// app/Console/Commands/SyncCurrencyRates.php
foreach ($provider->getRates() as $code => $rate) {
CurrencyRate::updateOrCreate(
['provider' => 'cbr', 'base' => 'RUB', 'quote' => $code, 'rate_date' => $today],
['rate' => $rate]
);
}
Normalise the base before you store anything. ECB quotes everything per EUR; CBR quotes everything per RUB with a Nominal multiplier. Pick one canonical base for your table and convert at ingest. Otherwise every consumer of the table has to remember which provider wrote the row.
Wrap it behind your own contract. The bundle's provider classes are a dependency you may want to drop later. Define App\Contracts\RateProvider with a single getRates(): array, and write two thin adapters around the bundle's providers. Swapping in a different feed — or a fixture provider for tests — becomes a container binding instead of a grep-and-replace.
Subclass the providers to regain control of HTTP. The bundle predates Laravel's HTTP client, so its fetching is whatever file_get_contents/cURL wrapper was idiomatic in 2014. Override the fetch method and delegate to Http::timeout(10)->retry(3)->get(...). This single change gets you timeouts, retries, proxies, and Http::fake() in tests — none of which you get otherwise.
class LaravelCbrProvider extends CbrProvider
{
protected function fetch(string $url): string
{
return Http::timeout(10)->retry(3, 200)->get($url)->body();
}
}
Stamp the rate onto the transaction. For orders, invoices, or anything with money in it, persist rate, rate_date, and provider on the record at the moment of conversion. Recomputing a historical total from a live rate table is a reconciliation bug waiting to happen, and the daily feed overwrites nothing but your joins will still drift.
Test against fixtures, never the network. Save one CBR response and one ECB response to tests/Fixtures/. The parsers are the only thing worth asserting on; the HTTP layer is faked. This also documents the exact XML shape you depended on if the upstream feed changes.
CBR's Nominal will silently cost you a factor of ten. The CBR feed quotes some currencies per 10 or per 100 units — JPY, KZT, and others carry <Nominal>10</Nominal>. Divide by it. If you skip this, yen conversions are off by an order of magnitude and nothing throws.
CBR XML is windows-1251 and uses comma decimals. The declaration says so; simplexml_load_string doesn't care but your string handling will. And <Value>91,2345</Value> needs str_replace(',', '.', ...) before any cast, or PHP truncates at the comma and you lose the fractional rate.
ECB has no per-currency date. The time attribute sits on the Cube element, not per currency. Parse it from there and treat the whole document as one observation date — don't stamp rows with now(), or a Monday holiday pull will look like Monday data and poison your history.
ECB dropped RUB in March 2022. Given this bundle presents CBR and ECB as interchangeable providers, that asymmetry matters: for a RUB-quoting app CBR is the only usable provider, and any fallback logic that assumes "try ECB if CBR fails" is wrong. Fail loudly instead.
Stale rates are a correctness problem, not a UX problem. Cache aggressively — the data changes once a day — but key on (provider, date) and make a cache miss for today's date an error you can see, not a silent fallback to yesterday. Serving Friday's rate on a Monday payment is a real loss.
Vendor risk is the headline. Three stars, zero dependents, last release May 2022. Treat it as a specification with working code attached, not a dependency. Pin a fork, or copy the two provider files into app/Support/Currency/ with a comment linking upstream — either is defensible; depending on it unmodified is not.
Rounding and precision. Keep rates as high-precision values (decimal(20,10) or bcmath strings), never floats, and round only at display or at the payment boundary. Rounding the rate before multiplying is the classic way to lose a kopeck per line item.
Extension point worth knowing: the bundle is provider-oriented, so adding a third feed (floatrates, OpenExchangeRates, your own bank) is a matter of implementing the same getRates() contract. That's the one piece of architecture here that survives the Symfony 2 baggage, and it's the piece to keep.
How can I help you explore Laravel packages today?