Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Multilingual plan content
Plan content — Subscription.name, description and content, plus the
snapshot copied onto BillableSubscription — can be stored per locale. This
page covers enabling that, reading and writing translations, the customer's
locale for frozen text and mails, and the limits. You need it only when you sell
the same plans in more than one language.
Off by default, and in single-language mode literally nothing changes.
- Enabling it
- Reading
- Writing
- The customer's locale
- Migrating existing content
- Admin UIs
- Limits and accepted degradations
Enabling it
List the content locales in locales.supported:
'locales' => [
'supported' => ['nl', 'en'], // or LARSASUB_LOCALES=nl,en; null = single-language
'default' => 'nl', // the language plain strings are in (LARSASUB_LOCALE_DEFAULT)
'fallback' => null, // read fallback; null = app.fallback_locale (LARSASUB_LOCALE_FALLBACK)
],
When default is null it is derived as the first supported locale —
deliberately not app.locale, which locale middleware mutates per request and
would make the default session-dependent. Setting it explicitly is recommended.
An explicitly configured default outside supported is a configuration error
and throws on write paths.
The same block holds locales.log, the language of model-log lines. That one
has nothing to do with plan content — see
Content locales.
Storage is a text column holding either a plain string (= every locale) or a
JSON locale map. A strict json column would reject the plain string, which is
what keeps single-language installs working unchanged.
Detection is anchored to the config. A value only counts as a translation map when multilang mode is on and every JSON key is a supported locale. So free-form JSON in
content({"url": ...}, API examples) can never be misread, and in single-language mode nothing is.The consequence: never narrow
locales.supportedwithout migrating stored maps first. Maps with now-unsupported keys become unrecognizable and render raw.
Reading
Plain property access always returns the current locale with fallback, so views, API resources and mails need no changes:
app()->setLocale('en');
$plan->name; // "Basic"
$plan->getTranslation('name', 'nl'); // "Basis"
$plan->getTranslation('name', 'en', false); // null unless 'en' is genuinely set
$plan->getTranslations('name'); // ['en' => 'Basic', 'nl' => 'Basis']
$plan->hasTranslation('name', 'de'); // false
Resolution order is current locale → fallback → default → first available, and it
never throws. toArray(), API resources and Blade all get plain strings; raw
maps only come out of getTranslations().
A plain, unpromoted stored string counts as the value for every locale —
including with useFallback: false — until it is promoted or translated.
getTranslations() reports it under the default locale.
Writing
Subscription::create(['name' => ['nl' => 'Basis', 'en' => 'Basic'], /* … */]);
$plan->setTranslation('name', 'en', 'Basic'); // one locale
$plan->setTranslations('name', ['nl' => 'Basis', 'en' => 'Basic']);
$plan->forgetTranslation('name', 'en'); // remove; fallback applies again
$plan->name = 'Basis v2'; // merges into the write locale
Array writes replace the full set. String writes merge into the write locale: the current app locale when that is a supported content locale, otherwise the default locale. Other translations always survive, which is what keeps any host form or API consumer that posts resolved strings lossless.
Re-saving an unchanged value is a no-op — untouched form fields never promote
or mislabel content. A stored plain string is promoted to {default: value} on a
real edit.
Setting a locale to null or '' removes it, so the fallback applies instead
of an empty string. An array write with a locale outside supported throws, and
array writes in single-language mode throw.
The translation methods come from Traits\HasTranslatableFields, and the
storage from the Casts\TranslatableText cast. Both are public, so you can use
them on a model of your own.
The customer's locale
Frozen text — charge descriptions on bank statements, invoice text and the
invoice PDF, credit-ledger entries — and user-facing notification mails follow
the customer's locale, stored on the Billable registration
(larsasub_billables.locale).
It is stored, and deliberately never read from the owner model on money paths: on scheduled and webhook paths the owner may be unreachable, or on the wrong database in multi-database tenancy.
LarsaSub captures it from the owner model's locale attribute whenever
BillableResolver::getOrCreateBillable() runs: that call creates the
registration on the first billing operation and refreshes a changed locale on
every later call. Point it at a different source by overriding the accessor
from Traits\IsBillableModel:
public function getBillableLocaleAttribute(): ?string
{
return $this->profile?->language; // any source; null = app default
}
Where nothing is stored, frozen text uses locales.default, else the first
supported locale, else app.locale.
Notification locale follows from that:
- the user funnel sets the notification locale to the stored billable
locale. With none stored, the recipient's own
HasLocalePreferenceapplies, and a recipient without one gets the owner'sbillable_locale; - the company funnel forces nothing at all, so admins keep their own language.
Cost-line descriptions are app-owned. Pass
SubscriptionCostService::addLine()descriptions in the billable's locale. The base breakdown entry is generated in that locale, so a viewer-locale add-on label produces a mixed-language invoice.
Migrating existing content
Existing plain strings have an unknown language; convert them explicitly once multilang mode is on:
php artisan larsasub:promote-translations nl
php artisan larsasub:promote-translations nl --dry-run
The argument is the language the existing strings are written in, and it must
be in locales.supported. The command promotes plan content (soft-deleted plans
included) and the name and description snapshots on subscriptions, and leaves
values that already are translation maps alone.
Until content is promoted or translated, viewers of other locales see the original string — by design, and without a signal.
Admin UIs
With multilang enabled, every bundled admin surface edits per locale.
Nova (larsasub-nova) and Filament (larsasub-filament) render one form field per supported locale. Only genuinely-set locales prefill, and the default locale is required for the name.
The tenant dashboard ships resolved strings for display plus raw maps for
editing: name_translations, description_translations, content_translations
({locale: value}, built from getTranslations(); a plain string reports only
the default locale) and a translations_orphaned flag.
Writes accept either:
- a plain string — a merge-write into the requester's write locale. Note this makes string writes session-dependent, so API consumers managing several languages should send maps; or
- a
{locale: value}map — a full replace. Keys must be supported locales, and the default locale is required for the name.
Building your own admin UI? Use this same payload shape, so behavior stays consistent across surfaces.
Content stored under locales that were later removed from supported renders as
empty fields and is refused on save — HTTP 422 on the tenant dashboard, a
validation error in Nova and Filament. Migrate the data or restore the locale
config first.
Limits and accepted degradations
- Lengths. Per-locale maximum lengths are name 64, description 510 and
content 65535 characters, enforced by the cast, not the database. The cast
also refuses a map whose serialized size would overflow its column.
contentis amediumTextcolumn because N locales × 65535 characters overflow MySQL's 64KBTEXT. - Package UI languages. Package UI strings ship in
enandnlonly. For a third content locale, the dashboards fall back to English and the mails toapp.fallback_locale. - Overriding UI texts. Publishing overrides (
--tag=larsasub-translations) covers what the SERVER renders — the mails, and on the subscribe card the state message and the VAT label. The texts infrontend.json(the dashboards' own labels, including the subscribe button "Order with obligation to pay" and "Due now") are built into the bundle: thefrontend.jsonthat--tag=larsasub-translationspublishes changes nothing. Changing them takes the Vue sources (--tag=larsasub-vue-components, which publishes its own copy offrontend.jsonbeside them) and your own build — see Using the Vue sources in your own build. - Sorting.
orderBy('name'), and Nova's index sort and search, operate on the raw stored text while multilang is enabled. The tenant dashboard sorts on the resolved name. - Frozen text. Frozen descriptions shown in dashboards keep their frozen language next to viewer-locale chrome — by design; they are snapshots. Invoice and credit-note PDFs render entirely in the billable's locale, whoever downloads them — see Document language.
- Feature labels. Tenant-defined feature labels stay untranslated. Only
platform-configured
features.labelsresolve through__(). - Never remove a locale from
supportedwhile translated content exists. Migrate the data first.
Do not use the
TranslatableTextcast for terms content. That cast is for mutable, merge-written content; terms are immutable snapshots.