i18n and translations
The hub, its emails and every pricing bullet render from a publishable translation namespace. It ships in English, German, Spanish, French, Italian, Dutch and Portuguese, and it is yours to override, extend or narrow.
Publishing the namespace
php artisan vendor:publish --tag=billing-lang
The files land under lang/vendor/billing/{locale}/. A published file overrides the shipped one key by
key: a key you leave out falls back to the package's translation, so you can publish a single locale and
change only the handful of strings you care about.
Regional locales
A regional locale reads the catalog of its language: pt-BR and pt-PT read pt, en-GB reads en, and so
does a region nobody listed, fr-CA or de_AT. A catalog you publish for the region, under
lang/vendor/billing/pt-BR/, wins over the language key by key, so a Brazilian wording needs only the keys that
differ. This holds for the billing namespace alone: your own translations keep Laravel's lookup, which goes
from the locale straight to app.fallback_locale.
Overriding a key
Publish the locale, then edit the key in the published file. Pricing bullets are the common case — a tier's
features block names translation keys, not raw text (see
Tiers and pricing), so you change the string in one place and every pricing surface
that renders it moves with it:
// lang/vendor/billing/en/pricing.php
return [
'pro' => [
'projects' => 'Unlimited projects', // was the shipped default; now yours
'priority_support' => 'Priority support',
],
];
Tier and add-on names
The label of a tier or an add-on in config/billing.php is your own string, and the configuration is built
before any locale exists. The package therefore shows it through your application's translations wherever a
person reads it: the account hub, the pricing cards and the activation email. Add the name as a key to the
JSON file of each locale you serve, lang/de.json for German, and a name you leave out is shown as it is:
{
"Advertising credit": "Werbeguthaben"
}
The payment description a provider records for an add-on keeps the configured name.
Adding or removing a locale
- Add one — create
lang/vendor/billing/{locale}/and translate the keys. Your app's own locale negotiation decides when it is served; the package reads whatever locale is active. - Remove one — if your app never serves a locale, you do not have to publish it. The package only renders the active locale, and an unshipped locale simply falls back through Laravel's normal chain.
Keep a locale you do serve complete: a missing key renders as the raw key string to a customer. The package's own locales are kept at full parity by a test (below); hold your overrides to the same bar.
Amounts and dates in the emails
An email writes an amount and a date the way its reader's language does: 29,00 € and 15. Oktober 2026
in German, €29.00 and October 15, 2026 in English. The month is spelled out because a mail is read long
after it was sent, and a numeric date such as 03/09/2026 names two different days in two languages.
The account hub writes its amounts the same way: prices, invoice totals, the credit balance, top-ups and
the preview of a plan change. Its counts follow the language too, so the usage screens write 1.500 and a
forecast of 2,5 per day in German, 1,500 and 2.5 in English. The admin console keeps 29.00 EUR, so its
figures read like the booking totals they stand beside.
An invoice document writes its amounts and tax rates in the language it is written in, the one its labels
come from: 100,00 € and 7,5 % on a German invoice, €100.00 and 7.5% on an English one. The e-invoice
XML is unaffected and keeps the machine form its standard prescribes.
Amounts and numbers go through ICU, which needs the intl extension. Without it an amount keeps its currency
code and takes the language's decimal mark, as in 29,00 EUR, and a number takes the decimal mark without
grouping, as in 1500. What a notification hands to a channel other than mail,
its toArray() payload, keeps the machine-readable forms 29.00 EUR and 2026-10-15.
The register: informal, hand-curated
The shipped namespace addresses the customer informally throughout — du in German, tu/tú in the
Romance locales — a deliberate house register, not a per-string choice. The translations are hand-curated
per locale, not machine-translated: the wording is chosen to read naturally in each language rather than
transliterated from the English. If you override a string or add a locale, keep that register — a formal
Sie/Vous in one place reads as a bug against the rest of the hub.
The parity and quality gate
Three tests hold the namespace to its contract, and they are the bar to hold your own overrides to:
- Key parity (
LocaleParityTest) — every locale carries exactly the same set of keys, so no locale is missing a string another has. - Informality (
TranslationQualityTest) — no formal-address marker slips into any locale. - Key existence (
LangKeyExistenceTest) — every key the code and views reference actually exists.
Run them with composer test; they are part of the same suite the coverage gate runs.