Skip to main content

Localization

This guide shows you how to run your store in several languages: which locales to support, how to pick the right one for each visitor, and how to translate your catalog. The locale reference covers each class and method in detail.

Larasell writes locales as BCP 47 tags, like en, de-DE or zh-Hans.

Choosing your locales

Publish the Larasell config if you haven't already:

1php artisan vendor:publish --tag=larasell-config

Then list the locales your store serves in config/larasell/locale.php and pick a default:

1use Larasell\Larasell\Enums\Locale;
2
3return [
4    'supported' => [Locale::ENGLISH, Locale::GERMAN, Locale::FRENCH_CANADA],
5    'default' => Locale::ENGLISH,
6];

The default locale is the language visitors see when none of their languages is supported. It's also the translation Larasell falls back to when a product has no text in the visitor's language. Pick the language your catalog is most complete in.

Use a bare language like de unless you really have different texts per country. A de translation serves visitors from Germany, Austria and Switzerland alike, because de-AT falls back to de. Add a regional locale like de-CH only for the texts that differ.

Choosing the locale for each request

Larasell doesn't change the locale for you. Every request uses config('app.locale') until your app sets a different one. Below are two common ways to set it.

From the browser's language

Browsers send the visitor's preferred languages in the Accept-Language header. LocaleSettings::negotiate picks the best supported locale for it. Create a middleware:

 1namespace App\Http\Middleware;
 2
 3use Closure;
 4use Illuminate\Http\Request;
 5use Illuminate\Support\Facades\App;
 6use Larasell\Larasell\Settings\LocaleSettings;
 7use Symfony\Component\HttpFoundation\Response;
 8
 9class NegotiateLocale
10{
11    public function __construct(private LocaleSettings $locales) {}
12
13    public function handle(Request $request, Closure $next): Response
14    {
15        $locale = $this->locales->negotiate($request->header('Accept-Language'));
16
17        App::setLocale($locale->value);
18
19        $response = $next($request);
20        $response->headers->set('Content-Language', $locale->value);
21        $response->setVary('Accept-Language', false);
22
23        return $response;
24    }
25}

Content-Language tells the client which language it got. Vary: Accept-Language stops caches and CDNs from serving a German page to an English visitor.

Register the middleware in bootstrap/app.php:

1use App\Http\Middleware\NegotiateLocale;
2use Illuminate\Foundation\Configuration\Middleware;
3
4->withMiddleware(function (Middleware $middleware): void {
5    $middleware->web(append: [NegotiateLocale::class]);
6})

This works well for APIs and for headless storefronts. A visitor from Switzerland whose browser sends de-CH,de;q=0.9,en;q=0.8 gets de. The reference explains how matching works.

From the URL

For a storefront that search engines index, put the locale in the URL, like /de/products/fahrrad. Every language then has its own URL, which search engines need to index each one.

 1namespace App\Http\Middleware;
 2
 3use Closure;
 4use Illuminate\Http\Request;
 5use Illuminate\Support\Facades\App;
 6use Illuminate\Support\Facades\URL;
 7use Larasell\Larasell\Enums\Locale;
 8use Larasell\Larasell\Settings\LocaleSettings;
 9use Symfony\Component\HttpFoundation\Response;
10
11class SetLocaleFromUrl
12{
13    public function __construct(private LocaleSettings $locales) {}
14
15    public function handle(Request $request, Closure $next): Response
16    {
17        $locale = Locale::tryFrom((string) $request->route('locale'));
18
19        abort_unless(in_array($locale, $this->locales->supported(), true), 404);
20
21        App::setLocale($locale->value);
22        URL::defaults(['locale' => $locale->value]);
23        $request->route()->forgetParameter('locale');
24
25        $response = $next($request);
26        $response->headers->set('Content-Language', $locale->value);
27
28        return $response;
29    }
30}

Group the localized routes under a {locale} prefix:

1use App\Http\Middleware\SetLocaleFromUrl;
2use Illuminate\Support\Facades\Route;
3
4Route::prefix('{locale}')->middleware(SetLocaleFromUrl::class)->group(function () {
5    Route::get('products/{product}', [ProductController::class, 'show'])->name('products.show');
6});

Unsupported locales return a 404, and so does any spelling other than the canonical one, like /de-de/. URL::defaults fills in the locale for you, so route('products.show', $slug) links stay in the current language. Forgetting the parameter keeps your controllers' signatures free of $locale.

You can combine both approaches: redirect / to the locale negotiate picks, and serve everything else from the URL.

Linking the languages of a page

Search engines find the other languages of a page through hreflang links in its <head>. With Inertia, render them in resources/views/app.blade.php, the root view. The server renders it on every full page load, and crawlers always load pages that way, so this works without SSR.

Build the links in your controller. Each language uses its own slug, so only link the languages the product has a slug for. Pass them with withViewData, which hands data to the root view without adding it to your page props:

 1use Inertia\Inertia;
 2use Inertia\Response;
 3use Larasell\Larasell\Enums\Locale;
 4use Larasell\Larasell\Http\Requests\ProductDetailRequest;
 5use Larasell\Larasell\Settings\LocaleSettings;
 6
 7public function show(ProductDetailRequest $request, LocaleSettings $locales): Response
 8{
 9    $product = $request->product();
10
11    $alternates = collect($locales->supported())
12        ->filter(fn (Locale $locale): bool => $product->slug->has($locale->value))
13        ->mapWithKeys(fn (Locale $locale): array => [
14            $locale->value => route('products.show', [
15                'locale' => $locale->value,
16                'product' => $product->slug->get($locale->value),
17            ]),
18        ])
19        ->all();
20
21    return Inertia::render('Products/Show', [
22        'name' => $product->name->get(),
23    ])->withViewData(['alternates' => $alternates]);
24}

Then render the links and the page language in app.blade.php:

1<html lang="{{ app()->currentLocale() }}">
2    <head>
3        {{-- ... --}}
4        @foreach ($alternates ?? [] as $hreflang => $url)
5            <link rel="alternate" hreflang="{{ $hreflang }}" href="{{ $url }}" />
6        @endforeach
7        <x-inertia::head />
8    </head>

Because the locale is already a BCP 47 tag, app()->currentLocale() needs no conversion for lang. Inertia doesn't render the root view again on client-side visits, so the links in the browser stay those of the first page. That's fine: they're only there for crawlers.

Translating your catalog

Translatable fields take an array keyed by locale:

1use Larasell\Larasell\Models\Product;
2use Larasell\Larasell\Price;
3
4Product::create([
5    'name' => ['en' => 'Bicycle', 'de' => 'Fahrrad'],
6    'slug' => ['en' => 'bicycle', 'de' => 'fahrrad'],
7    'price' => Price::of(19900),
8]);

Give every product a slug in every supported locale. Names and descriptions fall back to the default locale, but slugs don't. A product without a de slug can't be found at a German URL.

Adding a language

To add a language to a running store, first translate the catalog, then add the locale to supported. This way visitors never see a language that's only half done.

Find the products that still need a translation by querying the JSON key:

1use Larasell\Larasell\Models\Product;
2
3$untranslated = Product::whereNull('slug->fr')->get();

Add each translation with with, which keeps the existing ones:

1$product->update([
2    'name' => $product->name->with('fr', 'Vélo'),
3    'slug' => $product->slug->with('fr', 'velo'),
4]);

Categories work the same way. See Translating the product and Translating the category for all methods.

Translate the names of product attributes and their values too, or the variant picker shows "Size / Small" on an otherwise French page. See Translatable fields for every field that needs a translation.

Removing a language

Remove the locale from supported first, so visitors stop getting it. You can keep the translations in the database, or remove them with without:

1$product->update(['name' => $product->name->without('fr')]);

without returns null when it removes the last translation, so keep at least one locale.

Formatting prices

Price::format formats prices in the current locale, so prices follow the visitor's language once your middleware set it:

1use Larasell\Larasell\Enums\Currency;
2use Larasell\Larasell\Price;
3
4App::setLocale('de');
5Price::format(Price::of(19900), Currency::EUR); // '199,00 €'

The locale only changes how the price looks. It doesn't change the currency. A shop in Switzerland might serve de, fr and it but charge everybody in CHF, so keep choosing the currency separately.

Translation files

Your app's own texts live in Laravel's lang/ folder. App::setLocale('de-DE') loads lang/de-DE.json and lang/de-DE/*.php, so name these files and folders after the same tags you use in config/larasell/locale.php.

Community packages like laravel-lang ship folders with underscores, like lang/pt_BR. Rename them to lang/pt-BR when you install them.