LarsaSub is launching soon. Purchases and account registration open shortly.

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

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.supported without 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:

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:

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

Do not use the TranslatableText cast for terms content. That cast is for mutable, merge-written content; terms are immutable snapshots.