torann/currency
Laravel package for currency formatting, parsing, and conversion. Format and parse monetary values and convert between currencies using pluggable exchange rate providers, with caching and a simple Currency facade for everyday use.
Install and wire it up in four commands, then format one number to confirm the wiring:
composer require torann/currency
php artisan vendor:publish --provider="Torann\Currency\CurrencyServiceProvider"
Open config/currency.php first — it is the whole package in one file. You care about four things in it:
default — your base currency (e.g. 'USD'). This is the currency every stored amount is assumed to be in.thousand_separator, decimal_separator, decimal_count, and the symbol-placement flag — the formatting rules.cache — whether exchange rates are cached and for how long. Look at the TTL now; you will be asked about it later.drivers — the exchange-rate provider(s) and their API keys.Put the provider key in .env rather than in the config file, so the config stays committable and the secret stays in your normal secret path.
First use case — format a stored price. The whole point of the package is the one-liner:
use Torann\Currency\Facades\Currency;
Currency::format(1999.5); // "$1,999.50" using the default currency
Currency::format(1999.5, 'EUR'); // "€1,999.50"
If that string looks right, integration is done. Everything after this is policy, not plumbing.
Where to look next, in order:
config/currency.php (published copy — never edit the vendor one).format, convert, setCurrency, getCurrency, getExchangeRate. That is roughly the whole public API; there is not much to learn.vendor/torann/currency/src/Drivers/ — read the driver you configured. You need to know what it does on an HTTP failure, but you will only find that out by reading the code, not the README.Wrap it before you spread it. The facade is one use statement away from appearing in forty files. Don't. Put a single class in front of it on day one:
namespace App\Support;
use Torann\Currency\Facades\Currency;
final class Money
{
public static function format(int|float $amount, ?string $currency = null): string
{
return Currency::format($amount, $currency ?? Currency::getCurrency());
}
public static function indicative(int|float $amount, string $to, ?string $from = null): string
{
return '≈ ' . self::format(Currency::convert($amount, $to, $from), $to);
}
}
Two reasons: the package has effectively no adoption base, so you want a one-file swap if you replace it; and you want a single place to enforce the "≈" prefix so every converted price in the UI is visibly indicative rather than looking like a charged amount.
Store minor units, format at the edge. Keep prices in the database as integers (cents) and divide only at the display boundary. The package's conversion path goes through floats, so you want exactly one float operation in the whole request, at the last possible moment:
Money::format($product->price_cents / 100);
Never let a float round-trip back into persistence. If a code review sees $product->price = Currency::convert(...), that is a bug, not a feature.
Set the display currency once per request, in middleware. Not in a controller, not in a Blade template. Resolve it from the authenticated user's preference, then the session, then Accept-Language, then config default:
public function handle($request, Closure $next)
{
$code = $request->user()?->currency
?? session('currency')
?? config('currency.default');
Currency::setCurrency($code);
return $next($request);
}
Because setCurrency is stateful, doing this in one place means every format() call downstream picks up the right symbol without threading $currency through every view.
Keep conversion out of list rendering. A catalog page with fifty products must not trigger fifty rate lookups. Fetch the rate once and convert in PHP:
$rate = Currency::getExchangeRate('USD', $displayCode); // one call, cached
$prices = $products->map(fn ($p) => $p->price_cents / 100 * $rate);
Same rule for cart totals and order summaries: one rate, applied once to the aggregate, not once per line item. Rate-per-line-item also produces totals that don't equal the sum of their parts after rounding, which is a support ticket you can avoid for free.
Blade. Once the currency is set by middleware, views just call your wrapper:
{{ \App\Support\Money::format($plan->price_cents / 100) }}
<span class="text-xs text-gray-500">{{ __('Approx. :price, rates update daily', ['price' => \App\Support\Money::indicative($plan->price_cents / 100, 'EUR')]) }}</span>
That translation string is doing product work: it names the rate policy in the UI, which is the thing that prevents "why did I get charged a different amount" tickets.
Testing: pin the rate. Any test that asserts on a converted number must not touch the network. Two workable approaches — pick one and put it in your base TestCase:
getExchangeRate never leaves the process.setUp() and disable the driver.Then assert on exact strings: $this->assertSame('€42.00', Money::format(42, 'EUR')). Formatting assertions are cheap and they catch the separator/symbol regressions that sneak in when someone "tidies" the config.
Snapshot the rate when it matters. For anything a user can come back to — a quote, a saved cart, a displayed order total — store the rate and the display string alongside the order, not just the base amount. You can't reconstruct what the customer saw otherwise, and the package gives you today's rate only.
It's floats, and the docblock won't say so loudly enough. format() and convert() deal in floats. That is fine for a price label and unacceptable for anything that reconciles. If a number will be summed with other numbers, stored, or compared to an invoice, do the arithmetic in integer minor units yourself and hand the package only the final display value.
Per-currency decimals are not automatic. The config has one decimal count, so a JPY price will render with two decimals and a KWD price with two instead of three unless you set them explicitly. Build a small map and call format() with the right currency set first, or accept a per-currency config override. Test JPY and KWD specifically — they are the two that break naive formatting and the two nobody tests.
The cache TTL is the product decision. The rate cache is what stands between you and a network call per request, so it should be on. But its duration directly determines how stale a converted price can be. Look at the default, write the number down in the PR description, and make it configurable per environment. In staging, drop it to minutes so you notice staleness; in production, a day is usually right for indicative display and wrong for anything a customer is about to pay.
Provider failure is your failure. If the driver's HTTP call fails or the API key is missing, you either get a stale cached rate or an error depending on the driver and the cache state. Read your chosen driver's source to find out which, then decide the fallback: serve the base currency formatted, hide the converted price, or serve the last known rate with a "rates may be outdated" note. Do not let an exception from a formatting helper 500 a product page.
setCurrency leaks in long-running processes. Under Octane, queue workers, or any container that outlives a request, the previously set currency persists. Reset it at the start of each request/job (the middleware above does this for HTTP; do the equivalent in handle() of your jobs) or you will eventually see a job format a GBP amount as USD because of a request that ran five minutes earlier.
Symbol placement and swapping. Symbols go on the left by default; several European conventions put them on the right, and a few want a space. If a market complains, that's a config flag, not a code change — but verify the flag name in your published config, because these keys have shifted between versions and the README lags the code.
Publish the config and never edit the vendor copy. Beyond the obvious (a composer update wipes it), the published file is your diff target when you upgrade. If you find yourself needing a formatting rule the config can't express, that is a signal to extend your Money wrapper, not to fork the package.
Custom driver is the extension point. If you already have a contracted rate provider, don't bend a shipped driver — implement the package's driver contract in App\Currency\YourProviderDriver, register it in the config, and keep the vendor tree untouched. That also makes the package genuinely replaceable later, since your integration lives in your codebase.
Pin the version and check the licence before you depend on it. Effectively zero dependents and single-line release notes mean you are the maintenance backstop. A composer.json constraint like ^1.1 is not enough for a revenue-adjacent path — pin it, and read the licence now, because "can we fork this internally if the maintainer disappears" is a question you want answered before you need the answer.
Watch the PHP floor on upgrade. The Laravel 13.x compatibility note may imply a PHP minimum that is a harder infrastructure constraint than the package itself. Check your CI matrix before bumping, not after.
How can I help you explore Laravel packages today?