· 10 min read

Multi-Tenant Localization in Laravel: Locales Per Tenant Without Leaks

Single-tenant localization has one question: what language is this user? Multi-tenant has three, and they don't always agree. There's the tenant's configured default, the individual user's preference, and whatever the current request asks for. Add per-tenant copy customisation — where one customer wants "Members" and another wants "Employees" — and you have a system where getting precedence wrong produces cross-tenant leakage.

That last part is why this is worth being careful about. A wrong language is embarrassing. One tenant seeing another tenant's custom terminology is a data isolation incident.

Define the precedence explicitly

Write it down before you write code, because the implicit version is always wrong somewhere:

  1. Explicit request — ?lang= or a locale in the path. Highest priority, allow-listed.
  2. User preference — the individual's saved choice.
  3. Tenant default — the account's configured language.
  4. Application default — config('app.locale').

The interesting decision is 2 versus 3, and the right answer is user over tenant. A German-speaking employee at a company whose default is English should get German. The tenant default is a sensible starting value for new users, not an override on people who've made a choice.

final class SetTenantLocale
{
    public function handle(Request $request, Closure $next): Response
    {
        $locale = $this->resolve($request);

        App::setLocale($locale);
        Carbon::setLocale($locale);
        Number::useLocale($locale);

        return $next($request);
    }

    private function resolve(Request $request): string
    {
        $tenant = $request->user()?->account;

        $candidates = [
            $this->explicit($request, $tenant),
            $request->user()?->locale,
            $tenant?->default_locale,
            config('app.locale'),
        ];

        foreach ($candidates as $candidate) {
            if ($candidate && $this->isAllowed($candidate, $tenant)) {
                return $candidate;
            }
        }

        return config('app.locale');
    }

    private function explicit(Request $request, ?Account $tenant): ?string
    {
        $explicit = $request->query('lang');

        return $explicit ? str_replace('-', '_', $explicit) : null;
    }

    /**
     * A locale must be supported by the app AND enabled for this tenant.
     */
    private function isAllowed(string $locale, ?Account $tenant): bool
    {
        if (! in_array($locale, config('app.available_locales'), strict: true)) {
            return false;
        }

        return $tenant === null
            || $tenant->enabled_locales === []
            || in_array($locale, $tenant->enabled_locales, strict: true);
    }
}

Two things worth noting. The candidate list is ordered precedence expressed as data, which is much easier to reason about and test than nested ??. And every candidate goes through isAllowed() — including the user's own stored preference, because a tenant may have disabled a language after a user selected it.

Per-tenant terminology

The feature customers ask for: one calls them Projects, another calls them Campaigns. Two viable implementations, with quite different risk profiles.

Overriding lang lines per tenant

Laravel's translator lets you add lines at runtime:

Lang::addLines(['projects.label' => 'Campaigns'], 'en');

Keys must be in group.item form — addLines() splits on the first dot to decide where the line goes, so a key without one won't work.

The temptation is to load a tenant's overrides in a service provider. Don't: providers boot before the tenant is resolved from the request, and in a queue worker there's no tenant at all. Do it in middleware, after the tenant is known:

final class ApplyTenantTerminology
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = $request->user()?->account;

        if ($tenant) {
            foreach ($tenant->terminology as $group => $lines) {
                Lang::addLines(
                    collect($lines)->mapWithKeys(
                        fn ($value, $key) => ["{$group}.{$key}" => $value]
                    )->all(),
                    App::getLocale(),
                );
            }
        }

        return $next($request);
    }
}

Now the danger. Lang::addLines() mutates the translator instance, and in a long-running process — Octane, or a queue worker — that instance persists across requests and jobs. Tenant A's terminology stays loaded when Tenant B's request arrives on the same worker.

That's cross-tenant data leakage, and it will not reproduce under php artisan serve. It appears only under Octane or on a worker, intermittently, depending on which requests share a process.

If you use this approach, reset the translator per request:

// In the same middleware, before adding tenant lines
app()->forgetInstance('translator');

Then re-resolve. That discards the loaded-lines cache, so each request starts clean. It costs you the per-request group cache, which is a real but usually small price for correctness.

Better still, register an Octane listener so the reset isn't dependent on middleware ordering:

// config/octane.php
'listeners' => [
    RequestReceived::class => [
        // ...
        \App\Listeners\FlushTenantTranslations::class,
    ],
],

A database-backed override layer

The alternative avoids mutating global state: resolve terminology explicitly where you use it.

final class Terminology
{
    /** @var array<string, string> */
    private array $cache = [];

    public function __construct(private readonly ?Account $tenant) {}

    public function get(string $key, array $replace = []): string
    {
        $override = $this->cache[$key] ??= $this->tenant?->terminology[$key] ?? null;

        return $override
            ? __($override, $replace)
            : __($key, $replace);
    }
}
{{ $terminology->get('projects.heading') }}

More verbose at call sites, and it can't leak, because there's no shared mutable state — the tenant is bound into the instance. For anything where a leak would be a genuine incident, I'd take the verbosity.

Scope the instance per request in a service provider:

$this->app->scoped(Terminology::class, fn ($app) => new Terminology(
    $app['request']->user()?->account
));

scoped rather than singleton matters under Octane — scoped bindings are flushed between requests.

Queued jobs need the tenant, not the ambient state

Same problem as locale generally, compounded because tenant context also doesn't survive dispatch:

final class SendWeeklyDigest implements ShouldQueue
{
    use Queueable, Localizable;

    public function __construct(
        private readonly User $user,
        private readonly string $locale,
    ) {}

    public function handle(): void
    {
        $this->withLocale($this->locale, function () {
            $terminology = new Terminology($this->user->account);

            Mail::to($this->user)->send(new WeeklyDigest($this->user, $terminology));
        });
    }
}
SendWeeklyDigest::dispatch($user, $user->locale ?? $user->account->default_locale);

Resolve the locale per recipient at dispatch time, not once for a batch. A digest job looping over users of a tenant and rendering all of them in the tenant default sends German-speaking users English mail — the exact bug the precedence rules were supposed to prevent. If you're fanning out, dispatch one job per recipient.

The Localizable trait's withLocale() restores the previous locale in a finally, which prevents one job's locale leaking into the next on the same worker. See the emails post for that failure mode in detail.

Tenant-scoped locale availability

Two decisions worth making deliberately.

Which languages can a tenant enable? Usually a subset of what the app supports, possibly tied to their plan. Store it on the account and enforce it in isAllowed() as above — not just in the UI, since the locale can arrive by query parameter.

What happens when a tenant disables a language users had selected? Options are to leave the preference and fall through (my preference — reversible), or to migrate affected users to the new default (destructive but consistent). Whichever you choose, make sure isAllowed() covers stored preferences so a disabled language can't be served.

Onboarding and the first-run locale

A tenant's first user arrives before the tenant has a configured default, so there's a bootstrapping question worth answering deliberately.

The sequence that works: negotiate from Accept-Language for the signup page itself, then use whatever they signed up in as the tenant's initial default:

public function store(SignUpRequest $request): RedirectResponse
{
    $locale = app()->getLocale();   // already negotiated by middleware

    $account = Account::create([
        'name' => $request->validated('company'),
        'default_locale' => $locale,
        'enabled_locales' => [$locale],
    ]);

    $user = $account->users()->create([
        ...$request->safe()->only('name', 'email'),
        'locale' => $locale,
    ]);

    return to_route('dashboard');
}

Setting both the account default and the user preference from the same value means the second user at that company inherits a sensible language, and the first user's own preference is explicit rather than implied.

One decision to make consciously: should enabled_locales start as just their language, or as everything you support? Starting narrow means an admin has to enable languages before colleagues can pick them, which is an extra step but makes the tenant's language policy explicit. Starting wide is friendlier and means a tenant may find languages enabled that they never reviewed. I'd start narrow for B2B, wide for self-serve.

Invitations are sent before a preference exists

An invited user has no locale yet, so the invitation email has to use something else. The tenant default is the right answer — it's the best available guess about what language this company operates in:

SendInvitation::dispatch($invite, $invite->account->default_locale);

Using the inviting user's locale is a subtly worse choice: a German-speaking admin at an English-speaking company would send German invitations to English-speaking colleagues. The tenant default represents the organisation; the inviter's preference represents one person.

If the invitation link lands on a page where the invitee can choose a language before accepting, capture it then — that's the earliest point you have real information rather than a guess.

Testing isolation

The tests that matter here are the leakage ones, because everything else is straightforward:

it('does not leak tenant terminology between requests', function () {
    $a = Account::factory()->create(['terminology' => ['projects.label' => 'Campaigns']]);
    $b = Account::factory()->create(['terminology' => []]);

    $this->actingAs(User::factory()->for($a)->create())
        ->get('/projects')
        ->assertSee('Campaigns');

    $this->actingAs(User::factory()->for($b)->create())
        ->get('/projects')
        ->assertSee('Projects')
        ->assertDontSee('Campaigns');
});

it('prefers the user locale over the tenant default', function () {
    $account = Account::factory()->create(['default_locale' => 'en']);
    $user = User::factory()->for($account)->create(['locale' => 'de']);

    $this->actingAs($user)->get('/dashboard')
        ->assertSee(__('dashboard.heading', [], 'de'))
        ->assertDontSee(__('dashboard.heading', [], 'en'));
});

it('ignores a user preference the tenant has disabled', function () {
    $account = Account::factory()->create([
        'default_locale' => 'en',
        'enabled_locales' => ['en', 'es'],
    ]);
    $user = User::factory()->for($account)->create(['locale' => 'de']);

    $this->actingAs($user)->get('/dashboard')
        ->assertSee(__('dashboard.heading', [], 'en'));
});

it('rejects a locale the tenant has not enabled via query string', function () {
    $account = Account::factory()->create(['enabled_locales' => ['en']]);

    $this->actingAs(User::factory()->for($account)->create())
        ->get('/dashboard?lang=de')
        ->assertSee(__('dashboard.heading', [], 'en'));
});

The assertDontSee in the first test is the actual assertion — it's what fails when the translator carries state across requests. Note that this test may still pass under artisan serve and fail under Octane, so if you run Octane in production, run these tests against it too. A leak test that only runs in an environment without the leak isn't testing much.

Summary

  • Write the precedence down: explicit request → user preference → tenant default → app default. User beats tenant.
  • Validate every candidate against both app-supported and tenant-enabled locales, including stored preferences.
  • Lang::addLines() mutates shared state. Under Octane or on a worker it leaks across tenants — flush the translator per request, or avoid it.
  • A per-request scoped resolver is more verbose and can't leak. Prefer it where a leak would be an incident.
  • Use scoped(), not singleton(), for anything holding tenant context.
  • Resolve locale per recipient when fanning out queued mail, and use withLocale().
  • Write leak tests with assertDontSee, and run them under the runtime you deploy.

Per-tenant terminology multiplies your translation surface: every override needs a value in every language that tenant enables, and the gaps aren't visible from a lang file. LangSyncer reports coverage per language, fills gaps with AI for review, and publishes changes to production in seconds — which matters more when a customer is asking why their custom label is missing in French.

We use cookies to improve your experience and analyze site traffic. Cookie Policy

Cookie Preferences

Essential

Required for the site to work

Analytics

Help us improve the site

Marketing

Personalized ads and content