· 8 min read

Numbers, Currency and Percentages in Laravel: Locale Conventions Aren't Translations

A Spanish user sees 1,234.50 and reads it as one and a bit — the comma is their decimal separator. A German user sees €1,234.50 and notices the symbol is on the wrong side. Neither of these is a translation problem: no string in any lang file is wrong. They're locale conventions, and they need a different tool.

Laravel's Number class covers most of it, and it's underused relative to how much correctness it buys.

The formats really do differ

Same value, four locales, actual output:

| | en | es | de | ja | |---|---|---|---|---| | Number::currency(1234.5, in: 'EUR') | €1,234.50 | 1.234,50 € | 1.234,50 € | €1,234.50 | | Number::percentage(12.5) | 12% | 12 % | 12 % | 12% | | Number::spell(42) | forty-two | cuarenta y dos | zweiundvierzig | 四十二 |

Three separate conventions varying independently: the decimal separator, the thousands separator, and the symbol's position. Spanish and German put the euro sign after the number with a space; English and Japanese put it before. Spanish and German insert a space before %.

You are not going to reproduce this with number_format() and a conditional. The data lives in ICU, and Number is the way to reach it:

use Illuminate\Support\Number;

Number::currency($order->total, in: $order->currency, locale: $user->locale);
Number::percentage($rate, locale: $user->locale);
Number::format($count, locale: $user->locale);

Number requires the intl extension. If it's missing you'll get an exception rather than wrong output, which is the right failure mode — but check it's in your production image, since it's not always enabled by default.

Currency and locale are independent

This is the conflation worth untangling, because it produces real bugs.

Locale is how to format. Currency is what the money is. A German user paying in dollars should see German formatting of a dollar amount:

Number::currency(1234.5, in: 'USD', locale: 'de');   // 1.234,50 $

Deriving currency from locale is wrong:

// Don't. The user's language doesn't determine what they're charged in.
$currency = app()->getLocale() === 'de' ? 'EUR' : 'USD';

Currency belongs to the transaction — it's on the order, the subscription, the invoice — and it must not change based on who's looking. An invoice for €50 is €50 when viewed by an English-speaking accountant. Reformatting is fine; reinterpreting is a data integrity problem.

So: store the currency alongside the amount, and pass both.

Store money as integers

Adjacent but worth stating because it's the most common money bug in Laravel apps:

// Wrong
$table->decimal('total', 10, 2);   // then handled as float in PHP

// Right
$table->unsignedBigInteger('total_cents');
$table->char('currency', 3);

Floats can't represent most decimal fractions exactly, so arithmetic drifts:

0.1 + 0.2 === 0.3;      // false

Over a few operations that becomes a cent of discrepancy in an invoice total, which is the kind of bug finance notices and nobody can reproduce. Store minor units as integers, do arithmetic in integers, and convert only for display:

protected function casts(): array
{
    return ['total_cents' => 'integer'];
}

public function formattedTotal(?string $locale = null): string
{
    return Number::currency(
        $this->total_cents / 100,
        in: $this->currency,
        locale: $locale ?? app()->getLocale(),
    );
}

Two caveats on / 100. Not every currency has two decimal places — JPY has zero, so ¥1000 is 1000 minor units, not 100000. And KWD has three. If you handle more than a couple of currencies, get the exponent from ICU rather than hardcoding 100, or use a money library that already does.

Where to format

Formatting at the edge, not in the model, keeps things flexible. An accessor that bakes in app()->getLocale() will produce the wrong result in a queued job or an admin view of a customer's invoice.

For Blade, a small component reads well and centralises the decision:

{{-- resources/views/components/money.blade.php --}}
@props(['cents', 'currency', 'locale' => null])

<span class="tabular-nums" dir="ltr">
    {{ \Illuminate\Support\Number::currency(
        $cents / 100,
        in: $currency,
        locale: $locale ?? app()->getLocale(),
    ) }}
</span>
<x-money :cents="$order->total_cents" :currency="$order->currency" />

Two details in there that matter more than they look. tabular-nums makes digits monospaced so columns of figures align — worth having on any table of numbers. And dir="ltr" keeps the number readable inside an RTL page, since numbers don't mirror; see the RTL post for why that's necessary.

In an API: don't format at all

For JSON, send the raw values and let the client format:

public function toArray(Request $request): array
{
    return [
        'total_cents' => $this->total_cents,
        'currency' => $this->currency,
    ];
}

A formatted string is lossy — the consumer can't do arithmetic on "1.234,50 €" without parsing it back, and parsing localized number formats is exactly the work you were trying to save them. Send total_cents and currency; the client knows its own locale better than you do. Same reasoning as raw ISO timestamps in the API localization post.

If a consumer genuinely needs display strings — a thin client with no formatting layer — send both, clearly named:

'total_cents' => 123450,
'currency' => 'EUR',
'total_formatted' => Number::currency(1234.5, in: 'EUR', locale: $locale),

The rest of the class

Some of these are less known and save real work:

Number::format(1234.5678, precision: 2);          // "1,234.57"
Number::fileSize(1024 * 1024);                    // "1 MB"
Number::abbreviate(1250000);                      // "1M"  — precision defaults to 0
Number::abbreviate(1250000, precision: 2);        // "1.25M"
Number::forHumans(1250000);                       // "1 million"
Number::forHumans(1250000, precision: 2);         // "1.25 million"
Number::ordinal(3);                               // "3rd"
Number::spell(42);                                // "forty-two"
Number::percentage(12.5, precision: 1);           // "12.5%"
Number::clamp($input, min: 1, max: 100);

Two of these need care in a localized app.

forHumans() and abbreviate() produce words or letters — "million", "M" — which are language-dependent. They accept a locale, but verify the output for your languages rather than assuming; abbreviation conventions vary and some locales have no widely-used short form.

spell() is excellent for cheque-style amounts and accessibility, but grows long fast and word order varies by language. Don't build a sentence around it by concatenation — put it in a placeholder:

__('invoice.amount_in_words', ['words' => Number::spell($euros, locale: $locale)]);

Setting a default locale

Rather than passing locale: at every call site, set it once where you set the app locale:

public function handle(Request $request, Closure $next): Response
{
    $locale = $this->resolveLocale($request);

    App::setLocale($locale);
    Carbon::setLocale($locale);
    Number::useLocale($locale);

    return $next($request);
}

Three separate calls, because these are three independent systems: the translator, the date library and the number formatter. Setting App::setLocale() alone localizes strings and leaves dates and numbers in your default — which is exactly how apps end up "fully translated" with English dates and dollar-first currency.

Number::useCurrency() exists too, for a default currency. I'd skip it: an implicit currency is how you end up displaying euros as dollars. Pass currency explicitly, always.

For queued jobs, the same restoration concern as the locale applies — use withLocale() and set the number locale inside it, or pass the locale into the job and set all three at the top of handle().

Parsing input is the harder half

Formatting output is the easy direction. Accepting a number from a user in their own format is where apps get it wrong, and the failure is silent and expensive.

A German user typing 1.234,50 into a price field submits a string your PHP will happily misread:

(float) '1.234,50';   // 1.234 — a thousand-fold error, no warning

That's a €1,234.50 price stored as €1.23. Number::parse() handles it, using the locale's conventions:

Number::parse('1.234,50', locale: 'de');    // 1234.5
Number::parseFloat('1,234.50', locale: 'en');  // 1234.5

Two rules that follow from this.

Parse with the locale the input was produced in — the user's locale, not your server default. Passing the wrong locale turns a correct input into a wrong number rather than an error, which is the worst outcome.

Validate after parsing, not before. Laravel's numeric rule works on the raw submitted string, so a legitimately-formatted German number fails validation while a mis-parseable one may pass. Normalise in prepareForValidation():

protected function prepareForValidation(): void
{
    $this->merge([
        'amount' => Number::parse(
            (string) $this->input('amount'),
            locale: app()->getLocale(),
        ),
    ]);
}

The robust alternative, if you can control the frontend: submit a machine-readable value and display a formatted one. An <input type="number"> submits a dot-decimal value regardless of display locale, and a hidden field holding minor units removes the ambiguity entirely. Parsing localized input is a fallback for when you can't do that.

Percentages: check what you're passing

Worth a note because the argument is ambiguous in most codebases. Number::percentage(12.5) renders 12% — it treats the input as an already-scaled percentage, not a ratio. If your value is 0.125, multiply first:

Number::percentage($ratio * 100, precision: 1);   // 12.5%

Being consistent about whether a variable holds a ratio or a percentage — and naming it accordingly — avoids a class of off-by-100 bug that survives code review easily.

Summary

  • Number and currency formats vary in separator, grouping and symbol position. Use Number, not number_format().
  • Locale ≠ currency. Format by the viewer's locale; never derive currency from language.
  • Store money as integer minor units with a currency code. Don't use floats.
  • Not every currency has two decimal places — JPY has none, KWD has three.
  • Format at the edge (Blade component), not in model accessors that assume the current locale.
  • In APIs, send raw integers and currency codes; formatting is lossy.
  • Set App::setLocale(), Carbon::setLocale() and Number::useLocale() — three separate systems.
  • Verify forHumans()/abbreviate() output per language; wrap spell() in a translation key.

Numbers and dates are the localization work that lives in code rather than lang files, which is why they're routinely missed. The parts that are in files — labels, validation, emails, plurals — are where LangSyncer helps: import your existing files, see coverage per language, fill gaps with AI, publish without a deploy.

We use cookies to improve your experience and analyze site traffic. Cookie Policy

Cookie Preferences

Essential

Required for the site to work

Analytics

Help us improve the site

Marketing

Personalized ads and content