Multilingual reports
Language and formatting are two separate axes: the language drives translations and app()->getLocale(), the format locale drives dates, numbers and separators. Currency stays independent of both.
Language and format locale
The translation language is always a language code. The region comes from the format locale — not from a regional translation directory.
| language · de · en · fr | Sets App::setLocale(), drives __(), the module lang files and ReportSource::locale(). |
|---|---|
| formatLocale · de_CH · en_US · en_GB | Drives NumberFormatter, IntlDateFormatter and Carbon. May differ from the language. |
| lang/de/report.php | Regional translation directories such as lang/de_CH are not intended. Regions are formatting, not language. |
| de → de_CH · en → en_US | Without an explicit region the engine fills it from config/reports.format.locales or a host resolver. |
| app()->getLocale() | May technically be de_CH. The engine normalises it to language = de and formatLocale = de_CH. |
Translations in the report module
Every report brings its own texts. The ReportRegistry registers the lang directory automatically as a Laravel namespace using the key() prefix.
<?php
return [
'name' => 'Mehrsprachige Preisliste',
'title' => 'Preisliste',
'columns' => [
'product' => 'Produkt',
'price' => 'Preis',
],
];
<h1>{{ __('multilanguage::report.title') }}</h1>
<span>{{ __('multilanguage::report.language_label') }}: {{ $ctx->locale }}</span>
<span>{{ __('multilanguage::report.format_label') }}: {{ $ctx->formatLocale }}</span>
Module-local language files and how to use them in the Blade bands.
The namespace is the report key: multilanguage::report.name. The structure is lang/<language>/<group>.php; a top-level lang/de.php is not loaded by Laravel’s namespace loader.
Run locale and frame selector
The app language stays the user’s language. The report output can be overridden per run — through the option, the report class or the language selector in the preview frame.
| Reports::html($report, locale: 'en') | Overrides the language for this run. Reports::pdf() accepts the same parameter. |
|---|---|
| Report::locale() · Report::formatLocale() | Report-wide defaults when the call does not pass a locale. |
| LocaleScope | Sets app, Carbon and Number locales and restores the previous state in finally — also in jobs and queues. |
| ?locale=en | The frame selector sets locale on the preview URL; document, iframe, PDF and filter form pick it up. |
| config('reports.locales') | Allow-list for the selector. With only one locale the frame hides the selector. |
use Guggach\Reports\Reports;
$html = Reports::html('multilanguage', locale: 'en');
$pdf = Reports::pdf('multilanguage', locale: 'en')->stream();
<x-reports::frame
:url="$frameUrl"
:pdf="$pdfUrl"
:locales="$locales"
:locale="$locale"
/>
Language per call and the language selector in the frame.
Dates, numbers and currency
Bands format through the RenderContext. The formatter knows the format locale of the run (or record) and the explicit report currency.
| $ctx->number(2500.4) | Neutral number with the format locale separators, e.g. 2'500.40 in de_CH or 2,500.40 in en_US. |
|---|---|
| $ctx->currency(2500.4) | Formats with an explicit currency: Report::currency(), field metadata or the second parameter. |
| $ctx->date($value) | Default is MEDIUM: 18.09.2026 in de_CH, Sep 18, 2026 in en_US. |
| date:short · date:long · date:iso | SHORT only for compact tables, LONG for contracts, ISO for technical output. |
| $ctx->dateTime($value) · $ctx->time($value) | Date with time or time only, also in the format locale. |
Currency is not a locale property
en_US does not mean USD, de_CH does not mean CHF. There is no global currency default and no derivation from the locale. Without an explicit currency, currency() throws; number() always works. Multi-currency comes from a record field, without automatic conversion.
Data from the source
The engine sets app()->getLocale() before loading data. Eloquent models with spatie/laravel-translatable therefore return the right language automatically; language-column tables filter themselves.
use App\Models\Product;
public function records(): iterable
{
// app()->getLocale() is already set to the run language.
// spatie/laravel-translatable reads the ambient locale lazily.
return Product::query()->get();
}
public function records(): iterable
{
return DB::table('products')
->where('language', $this->locale())
->get();
}
use Guggach\Reports\Localization\ReportLocale;
public function records(): iterable
{
$language = explode('_', ReportLocale::normalize(
$this->locale() ?? app()->getLocale(),
))[0];
$records = [
['product' => ['de' => 'Studio Miete', 'en' => 'Studio rent']],
];
return array_map(function (array $record) use ($language): array {
$record['product'] = $record['product'][$language]
?? $record['product']['en'];
return $record;
}, $records);
}
Three patterns: translatable models, language columns and language arrays in the demo source.
Grouping and sorting keys should be language-neutral. Translate group data through enrichWith(); groups themselves stay in the run locale.
Locale per record
When one run contains invoices in different languages, the detail band resolves the locale per record. Each form instance renders in its own LocaleScope.
| ->localeField('language') | Reads the language from a source field. Alternatively localeUsing(fn ($row) => …). |
|---|---|
| ->formatLocaleField('format_locale') | Optional format locale per record; if missing, it is filled from the language. |
| $ctx->locale · $ctx->formatLocale | The record’s RenderContext contains the resolved values and a matching ReportFormatter. |
| Groups: run locale | Group headers and footers stay in the run locale. Translated group data comes from enrichWith() on Eloquent. |
$r->freeform(view: 'invoice')
->localeField('language')
->formatLocaleField('format_locale');
return [
['invoice_id' => 1, 'language' => 'de', 'format_locale' => 'de_CH'],
['invoice_id' => 2, 'language' => 'en', 'format_locale' => 'en_US'],
];
Several invoices in one run, each with its own language and format.