phpviet/number-to-words
phpviet/number-to-words converts numeric values into readable Vietnamese words. It handles integers, decimals, and currency amounts, making it easy to generate invoices, receipts, and payment notices with properly formatted Vietnamese text. Simple API, framework-agnostic, installs via Composer.
Install
composer require phpviet/number-to-words
Plain PHP — the two-minute version
use PHPViet\NumberToWords\Transformer;
$transformer = new Transformer();
echo $transformer->toWords(123456789);
// "một trăm hai mươi ba triệu bốn trăm năm mươi sáu nghìn bảy trăm tám mươi chín"
Laravel — the package ships a service provider that is auto-discovered, so you can either resolve the transformer out of the container or use the facade:
use PHPViet\NumberToWords\Facades\NumberToWords;
NumberToWords::toWords(105); // "một trăm lẻ năm"
Confirm the exact FQCNs and facade alias in
vendor/phpviet/number-to-words/composer.json(extra.laravel) andsrc/before you build on them — the surface is tiny, so a two-minute read ofTransformer.phpis worth more than any docs page.
Where to look first, in this order
src/Transformer.php — this is the package. The whole algorithm is one class; read it end to end so you know exactly which reading conventions it picks (see Gotchas).composer.json — PHP constraint, illuminate/support constraint, and whether there are any runtime deps at all.tests/ — this is a 21-star, single-maintainer package last released in October 2021. The test suite is your only real specification; run it locally (vendor/bin/phpunit) on your target PHP version before committing to the dependency.First use case to ship: the Bằng chữ: line on a Vietnamese invoice, receipt, or quotation. That single field is 90% of why anyone installs this, and it's a perfect smoke test because accounting staff will immediately tell you if the wording is wrong.
The package's API is one method and it hasn't changed since 2021. Give yourself a seam so a fork or replacement is a one-file change:
namespace App\Support;
use PHPViet\NumberToWords\Transformer;
class AmountInWords
{
public function __construct(private Transformer $transformer) {}
/** Reads a VND amount the way an invoice does: "Một triệu đồng chẵn" */
public function vnd(int|string $amount): string
{
$words = $this->transformer->toWords((int) $amount);
return mb_convert_case($words, MB_CASE_TITLE, 'UTF-8') . ' đồng chẵn';
}
}
Bind it as a singleton in a service provider (or just let Laravel autowire it) and inject it. Never call NumberToWords:: from a controller or Blade file.
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function totalInWords(): Attribute
{
return Attribute::get(
fn () => app(\App\Support\AmountInWords::class)->vnd($this->total)
);
}
Add total_in_words to $appends only on the resources that need it — it's a pure-compute field with no cache, so appending it to every list response is wasted CPU on index pages.
return [
'total' => $this->total,
'total_in_words' => $this->when(
$request->boolean('with_words'),
fn () => app(AmountInWords::class)->vnd($this->total)
),
];
Printing/payment clients ask for it; dashboards don't. Gate it behind a query flag or a dedicated InvoicePrintResource.
<p class="amount-in-words">
Bằng chữ: <strong>{{ app(\App\Support\AmountInWords::class)->vnd($invoice->total) }}</strong>
</p>
Critical: Vietnamese diacritics require a Unicode TTF in dompdf/mpdf — DejaVu Sans or Noto Sans, embedded and subset. The default Helvetica will render một as m?t and you'll spend an afternoon blaming the package.
VND has no minor unit, so bigInteger đồng is exact. This sidesteps every float-precision argument about toWords(1e15) and lets you hand the package a clean int:
$table->unsignedBigInteger('total'); // đồng, not a decimal
If you must support decimals (foreign currency), split the integer and fractional parts yourself and render them as two calls — the package has no built-in decimal phrasing.
Don't assert toWords(15) === $someOutputYouCopiedFromThePackage — that tests nothing. Write down what your accountant says is correct and assert that:
#[DataProvider('amounts')]
public function test_vnd_readings(int $amount, string $expected): void
{
$this->assertSame($expected, app(AmountInWords::class)->vnd($amount));
}
public static function amounts(): array
{
return [
[0, 'Không đồng chẵn'],
[15, 'Mười lăm đồng chẵn'],
[21, 'Hai mươi mốt đồng chẵn'],
[105, 'Một trăm lẻ năm đồng chẵn'],
[1_000_000, 'Một triệu đồng chẵn'],
];
}
This table becomes your regression suite the day the package goes stale and you have to fork it.
If you already use phpviet/laravel-validation, Vietnamese-specific rules (phone, citizen ID, bank account) live there and pair naturally with this package — e.g. validating a citizen ID and echoing it back "bằng chữ" on a contract PDF. Keep the two packages independently upgradable.
It has been frozen since October 2021. That's the headline risk. Before adopting:
illuminate/support constraint in composer.json. If it caps below your Laravel version, you'll be fighting the resolver — composer require --with-all-dependencies won't save you, you'll need a fork or a replace entry.Transformer.php into app/Support/ and deleting the dependency is a legitimate, low-drama option for a package this size — and it lets you fix wording without waiting for a maintainer who may have moved on.Convention mismatches are the #1 support ticket. Vietnamese number reading has regional/domain variants and the package picks exactly one:
| Case | Variants you'll see in the wild |
|---|---|
| 105 | một trăm lẻ năm vs một trăm linh năm |
| 24 | hai mươi tư vs hai mươi bốn |
| 25 | hai mươi lăm (never năm) |
| 1,000,000 | một triệu vs một ngàn triệu |
| 0 | không vs empty string |
There is almost certainly no config file to switch these. Read Transformer.php, note which it produces, and if accounting demands another form, wrap with a targeted str_replace post-processor (fragile) or fork (correct). Decide up front; don't discover it in production.
It will not preserve leading zeros. Feeding it "0123" or a phone number gives you một trăm hai mươi ba — the zeros are gone before the algorithm ever sees them. This package reads quantities, not digits. For phone numbers, ID numbers, or account numbers you want digit-by-digit reading (không một hai ba), which you'd have to write yourself.
Type coercion traps. If the signature is toWords(int $number), passing a "1.000.000" string or a formatted value from your DB is a silent 1 in PHP 7 semantics and a TypeError in strict mode. Cast at the boundary ((int) $amount) and validate the raw input before the cast, or "abc" becomes 0 becomes Không đồng.
Negative and zero. Refunds produce negative totals. Check what toWords(-5) does before it ends up on a credit note — most likely you need to handle the sign yourself and emit Âm ... or restructure the document.
Int range. PHP_INT_MAX is ~9.2×10¹⁸ on 64-bit. Anything approaching that (hyperinflation scenarios, aggregate sums, test fixtures like PHP_INT_MAX) is worth a manual check; on 32-bit PHP the ceiling is 2.1×10⁹, which a real invoice total can exceed.
Capitalisation is on you. The output is lowercase. Don't reach for ucfirst() — it operates on bytes and will mangle the first multibyte character. Use mb_convert_case($s, MB_CASE_TITLE, 'UTF-8') or mb_strtoupper(mb_substr($s, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($s, 1, null, 'UTF-8').
UTF-8 discipline everywhere downstream. The package emits UTF-8; your DB connection charset (utf8mb4), your Content-Type header, your Excel export library, and your PDF font must all agree. The package is never the bug here, but it's always the first place people look.
Long-running workers / Octane. If the transformer caches parsed dictionaries in instance properties, a singleton resolved once may carry state across requests. It's a one-method, stateless-looking class so this is likely fine — but grep for private array/static before you register it as a singleton under Octane. app(Transformer::class) per call is cheap insurance.
Debugging recipe. When a reading looks wrong, isolate: (a) is the input the integer you think it is — var_dump($n) right before the call; (b) is the reading wrong, or is it your wrapper's suffix/chẵn/capitalisation; (c) does the package's own test suite pass for that number? If (c) is "no", you've found a genuine gap — write the failing case into your fixture table and fix it in the fork.
Prefer the fork over the PR. With 21 stars and a 2021 release cadence, upstreaming a fix is a coin flip. Since the code is MIT and self-contained, own the file, keep the license header, and add your own tests. That's the honest assessment for a dependency this small and this quiet.
How can I help you explore Laravel packages today?