kwn/number-to-words
A PHP library that converts numbers and currency amounts into readable words across many languages. Use number or currency transformers via RFC 3066 language identifiers, call toWords(), or a static helper. Install with Composer.
Install and pin the major version
composer require kwn/number-to-words:^3.0
v3 requires PHP 8.2+, so it drops into Laravel 10/11/12 projects without fuss. It is not a Laravel package: there is no service provider, no config file, no facade. You wire it yourself.
Where to look first
Number / Currency). Several languages have numbers but no currency transformer (sv, it, nl, cs, et, fr_BE, mk, fa, ku). That single fact drives most of your design decisions.src/NumberToWords.php — the "manager" and the only entry point you need 95% of the time: getNumberTransformer($locale), getCurrencyTransformer($locale), plus the static transformNumber() / transformCurrency() shortcuts.src/Locale/<Lang>/… — the actual transformers. These are your reference implementation when output looks wrong.tests/Locale/… — golden strings for every language. When a stakeholder says "that's not how we say 1,234 in Polish", the repo's own test data is the fastest way to confirm whether you found a bug or a misunderstanding.First use case: "total in words" on an invoice
use NumberToWords\NumberToWords;
$manager = new NumberToWords();
$manager->getNumberTransformer('en')->toWords(5120);
// "five thousand one hundred twenty"
// Amounts are MINOR UNITS (cents) and integers only.
$manager->getCurrencyTransformer('en')->toWords(5099, 'USD');
// "fifty dollars ninety nine cents"
That two-liner is the whole library. Everything below is about making it safe inside a Laravel app.
Wrap it in one injectable service (do this on day one)
Creating transformers per call is cheap but pointless churn, and it scatters locale handling across your codebase. Bind a singleton and memoize per locale:
namespace App\Support;
use NumberToWords\NumberToWords;
use NumberToWords\Transformer\NumberTransformer;
use NumberToWords\Transformer\CurrencyTransformer;
class MoneyWords
{
private NumberToWords $manager;
/** @var array<string, NumberTransformer> */
private array $numbers = [];
/** @var array<string, CurrencyTransformer> */
private array $currencies = [];
public function __construct()
{
$this->manager = new NumberToWords();
}
public function number(int $value, ?string $locale = null): string
{
$locale = $this->locale($locale);
return ($this->numbers[$locale] ??= $this->manager->getNumberTransformer($locale))
->toWords($value);
}
/** @param int $minorUnits amount in cents */
public function currency(int $minorUnits, string $currency, ?string $locale = null): string
{
$locale = $this->locale($locale);
return ($this->currencies[$locale] ??= $this->manager->getCurrencyTransformer($locale))
->toWords($minorUnits, $currency);
}
private function locale(?string $locale): string
{
$locale ??= app()->getLocale();
return config('number-to-words.locales')[$locale]
?? config('number-to-words.fallback', 'en');
}
}
// AppServiceProvider::register()
$this->app->singleton(\App\Support\MoneyWords::class);
Map Laravel locales to package locales in config
The package uses RFC 3066-ish identifiers that do not always match Laravel's:
// config/number-to-words.php
return [
'fallback' => 'en',
'locales' => [
'en' => 'en',
'pl' => 'pl',
'de' => 'de',
'fr' => 'fr',
'fr_FR' => 'fr_FR', // alias for fr
'uk' => 'ua', // Ukrainian — NOT "uk"
'da' => 'dk', // Danish — NOT "da"
'pt_BR' => 'pt_BR',
'pt_PT' => 'pt_PT',
],
];
Model accessor for documents
protected function totalInWords(): Attribute
{
return Attribute::get(fn () => app(MoneyWords::class)->currency(
(int) round($this->total * 100),
$this->currency,
$this->locale,
));
}
Append it in the API resource rather than the model so PDFs and JSON stay in sync. For generated invoices/PDFs, persist the string at issue time in a total_in_words column: the wording then never changes retroactively when you bump the library version.
Blade directive
Blade::directive('words', fn ($expr) =>
"<?php echo e(app(\App\Support\MoneyWords::class)->number((int) ($expr))); ?>");
<p>@words($invoice->total_cents / 100)</p>
Prefer passing an already-integer value; the cast above is a guard rail, not a substitute for rounding.
Validation rule for supported combos
Since currency support is per-language, validate up front instead of throwing at render time:
class CurrencyInWordsSupported implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$currency = strtoupper((string) $value);
if (! in_array($currency, config('number-to-words.supported.'.$this->locale, []), true)) {
$fail("Currency {$currency} has no words transformer for {$this->locale}.");
}
}
}
Keep supported as a hand-maintained list derived from the README table, and add a feature test per currency you actually ship.
Custom or missing language
Implement the package interfaces — they are typed int in v2+:
use NumberToWords\Transformer\NumberTransformer;
class KlingonNumberTransformer implements NumberTransformer
{
public function toWords(int $number): string { /* ... */ }
}
Then inject it directly, or subclass NumberToWords and extend its locale→class mapping (see how the manager resolves locales in src/NumberToWords.php). For a language that has numbers but no currency transformer, extend the existing currency transformer and add your currency names — don't fork the package.
5099 means $50.99. Multiply, but round: 45.67 * 100 is 4566.999… in floating point, so (int) ($x * 100) silently undercharges by a cent in words. Use (int) round($x * 100).int; passing 45.67 throws a TypeError (or triggers a deprecation on older paths). Cast at the boundary, never inside a loop.uk/ua, da/dk, and fr_FR (the only alias in the library) all need mapping. An unmapped locale either throws or silently falls back, depending on how you wrap it — so wrap it explicitly, as in the service above.fr_BE, cs, nl, et, it, ku, mk, fa, sv have no currency transformer at all. Falling back to en for a Swedish invoice produces mixed-language output — decide deliberately (fallback vs. numeric-only display) and test it.'' before 3.0.0. For refunds/credit notes, prefer abs() plus your own localized "minus"/"minusem" prefix, and never treat an empty string as "zero is fine".MalaysianCurrencyTransformer → MalayCurrencyTransformer and changed the Romanian code ROL → RON. If you referenced transformer classes directly (or stored ROL), that breaks.NumberToWords::transformNumber('en', 5120) is fine in a seeder or a one-off, wasteful inside a PDF loop over 500 line items. Use the singleton service.max: rule on cents — rather than discovering a wrong output on a six-figure invoice.#[DataProvider] tests, mirroring the repo's style. Wording is business-visible; a silently changed connector word is a support ticket.getCurrencyTransformer('en') directly — the abstraction earns its keep only on multi-language documents.How can I help you explore Laravel packages today?