Multitenancy
Two distinct concepts at play in a multi-tenant app:
- Supported locales (
config('localizer.supported_locales')): the static union, evaluated at boot time. Drives route registration - every locale here gets a registered route variant. Cannot change per request without breakingroute:cachecompatibility. - Active locales (runtime): the subset the user is allowed to reach in the current request. Defaults to the supported set. Can be narrowed at runtime via
Localizer::setActiveLocales([...]). - Default locale (runtime): which locale is the unprefixed one when
hide_default_localeis on. Defaults toconfig('app.fallback_locale'). Can be overridden per request viaLocalizer::setActiveDefaultLocale(...).
The classic use case: in a multi-tenant app, each tenant exposes a different subset of the globally supported locales - and possibly a different default language. Tenant A allows en + de with en default, Tenant B allows en + fr + es with fr default. Configure the union of all locales in supported_locales, then narrow + redefine the default per request in middleware.
Tenant middleware example
// app/Http/Middleware/TenantLocales.php
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use NielsNumbers\LaravelLocalizer\Facades\Localizer;
class TenantLocales
{
public function handle(Request $request, Closure $next)
{
$tenant = $request->tenant(); // your resolver
Localizer::setActiveLocales($tenant->supported_locales);
Localizer::setActiveDefaultLocale($tenant->default_locale);
try {
return $next($request);
} finally {
// Reset for long-running workers (Octane, queue workers).
// The Localizer is a container singleton; without reset
// the override leaks into the next request on the same
// worker process.
Localizer::setActiveLocales(null);
Localizer::setActiveDefaultLocale(null);
}
}
}Middleware order
TenantLocales must run before SetLocale so that SetLocale validates incoming locale candidates against the narrowed subset and falls back to the tenant's default locale (not the global one):
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\TenantLocales::class,
\NielsNumbers\LaravelLocalizer\Middleware\SetLocale::class,
\NielsNumbers\LaravelLocalizer\Middleware\RedirectLocale::class,
]);
})Why not just Config::set('app.fallback_locale', ...)?
It's tempting to mutate the Laravel config per request to swap the default locale. Three problems with that:
fallback_localeis overloaded. It's also Laravel's translation fallback - flipping it per tenant changes translation behavior, not just URL behavior. Two unrelated concepts coupled by accident.- Octane / worker leaks. The
Configrepository is a singleton that survives across requests. Mutating it without a reset hook causes Tenant A's value to leak into Tenant B's next request on the same worker. - Boot-time consumers. Some package internals (and userland code) read config at boot. Mid-request mutations don't reach them.
setActiveDefaultLocale() avoids all three: the override lives on the Localizer singleton (request-scoped with the reset pattern above), doesn't touch Laravel's translation config, and is read at request time by every consumer that needs it.
What changes vs. the default behavior
- A request to a route for an inactive-but-supported locale (e.g.
/fr/abouton Tenant A) is treated as if the prefix isn't a locale at all -SetLocalefalls back to the resolution chain (session → cookie → detectors → tenant's default locale), andRedirectLocaledoesn't strip or add the inactive prefix. Route::localizedSwitcherUrl()and friends still iteratesupportedLocales(). If you build a switcher, filter againstLocalizer::activeLocales()yourself when rendering.route('about')resolves the same as before - the underlying routes for inactive locales still exist physically; the package just won't route the user there via locale detection.hide_default_localefollows the active default locale, not config. With Tenant B (frdefault),route('about')for afrrequest resolves to/about(no prefix); a/fr/aboutrequest gets 302'd to/aboutbyRedirectLocale.
Caveat: Route::translate() and per-tenant defaults
Route::translate()does not work correctly when tenants have different default locales. UseRoute::localize()instead.
The reason is timing. Service providers register routes at boot; your TenantLocales middleware sets setActiveDefaultLocale() at request time. Boot runs first, middleware second - so when TranslateMacro::register() decides which locale gets the unprefixed without_locale.* variant, no override has been set yet and it falls back to config('app.fallback_locale').
1. BOOT (once per worker)
routes/web.php → Route::translate(...)
→ TranslateMacro::register() runs
→ reads config('app.fallback_locale') = 'en'
→ registers:
translated_en.about → /en/about
translated_de.about → /de/ueber
without_locale.about → /about ← baked against 'en'
2. REQUEST (Tenant B with 'de' default)
TenantLocales::handle()
→ Localizer::setActiveDefaultLocale('de') ← too late!
→ routes are already registered; the unprefixed variant
is /about (English path), not /ueber (German path)The route table is fixed after boot - the runtime override has nothing to rewrite. With Route::localize() this isn't a problem, because every locale shares the same URI (/about); only the prefix differs, and the prefix is stripped at URL-generation time, not at registration time.
For multi-tenant apps with different defaults, use Route::localize(). If you really need translated paths and per-tenant defaults, you'll have to register per-locale without_locale.* routes yourself and extend UrlGenerator to pick between them - see the source of TranslateMacro for a starting point.
API summary
| Method | Purpose |
|---|---|
Localizer::supportedLocales() | Static union from config (boot-time). |
Localizer::activeLocales() | Runtime subset; defaults to supported. |
Localizer::isSupported($locale) | Membership in supported. |
Localizer::isActive($locale) | Membership in active. |
Localizer::setActiveLocales($array|null) | Narrow (or reset with null). |
Localizer::defaultLocale() | Runtime default; defaults to app.fallback_locale. |
Localizer::setActiveDefaultLocale($string|null) | Override (or reset with null). |