michele-angioni/multi-language
A Laravel package for building multi-language applications, with tools for managing translations, localized routes, and language switching so your app can serve content in multiple languages.
The package is a thin layer over Laravel's native localization: it gives you a languages table + Language model, a config file, and (usually) a route middleware that reads a {locale} route parameter and calls App::setLocale(). Treat it as a bootstrap for URL-prefixed locales, not a full i18n suite — you still supply the translations, the switcher UI, and the localized content model.
1. Install and pin. It's a Laravel 5-era package (last release Feb 2017), so constrain both sides:
composer require michele-angioni/multi-language
If you're on Laravel 5.4/5.5 the constraint resolves cleanly; on 5.6+ expect to fork and patch removed helpers (see Gotchas). Check composer.json first — the illuminate/* requirement tells you immediately whether this is a drop-in or a fork job.
2. Register the provider. Laravel 5.5 package auto-discovery was not standard when this shipped, so register manually in config/app.php:
'providers' => [
// ...
MicheleAngioni\MultiLanguage\MultiLanguageServiceProvider::class,
],
'aliases' => [
// ...
'MultiLanguage' => MicheleAngioni\MultiLanguage\Facades\MultiLanguage::class,
],
(Confirm the exact FQCNs by opening vendor/micheleangioni/multi-language/src — the facade class name is the one thing worth verifying before you wire up views.)
3. Publish and migrate.
php artisan vendor:publish --provider="MicheleAngioni\MultiLanguage\MultiLanguageServiceProvider"
php artisan migrate
4. Seed the languages. The migration creates the schema but almost never the rows you actually want. Add a seeder with updateOrCreate so it's re-runnable:
// database/seeds/LanguageSeeder.php
foreach ([
['slug' => 'en', 'name' => 'English', 'default' => true],
['slug' => 'it', 'name' => 'Italiano', 'default' => false],
] as $lang) {
Language::updateOrCreate(['slug' => $lang['slug']], $lang);
}
First use case: prefix every web route with {locale} and attach the package middleware to a group. That single move gets you /it/chi-siamo alongside /en/about, which is what 90% of "multi-language" tickets actually ask for.
// routes/web.php
Route::group([
'prefix' => '{locale}',
'middleware' => 'locale', // registered in app/Http/Kernel.php
], function () {
Route::get('/', 'HomeController@index')->name('home');
});
// app/Http/Kernel.php $routeMiddleware
'locale' => \MicheleAngioni\MultiLanguage\Http\Middleware\SetLanguage::class,
Look here first, in this order: config/multi-language.php (or whatever the publish tag produces — the default-locale key and redirect behaviour live there), the middleware class (that's the whole runtime behaviour), then the Language model (scopes/helpers like default-language lookup).
Constrain the locale segment — this is the highest-value five minutes you'll spend. An unconstrained {locale} swallows /admin, /login, and every future top-level path. Build the constraint from the DB once per request (cache it):
$slugs = Cache::rememberForever('language_slugs', fn () => Language::pluck('slug')->all());
Route::group([
'prefix' => '{locale}',
'middleware' => 'locale',
'where' => ['locale' => implode('|', $slugs)],
], function () { /* ... */ });
Make the middleware do more than setLocale. Wrap/replace the package middleware with your own that also sets Carbon, the translator fallback, and records the active Language instance for later use:
public function handle($request, Closure $next)
{
$slug = $request->route('locale');
$language = Language::where('slug', $slug)->firstOrFail();
App::setLocale($language->slug);
Carbon::setLocale($language->slug);
View::share('currentLanguage', $language);
View::share('languages', Language::all());
return $next($request);
}
Note the firstOrFail() — combined with the route constraint it makes unknown locales a 404 rather than a silent fallback to English, which is what you want for SEO.
One helper for link generation, used everywhere. Manually threading ['locale' => app()->getLocale()] through 200 route() calls is where this pattern usually rots:
function lroute(string $name, array $params = [], bool $absolute = true): string
{
return route($name, array_merge(['locale' => app()->getLocale()], $params), $absolute);
}
Blade: <a href="{{ lroute('products.show', $product) }}">. Because locale is just another route parameter, route('products.show', $product) without it will throw UrlGenerationException — good, it fails loudly instead of silently emitting the wrong URL.
Language switcher that preserves context. Never hardcode the home page as the switch target:
@foreach ($languages as $language)
<a href="{{ lroute(Route::currentRouteName() ?: 'home',
array_merge(request()->route()->parameters(),
['locale' => $language->slug])) }}"
hreflang="{{ $language->slug }}"
@class(['active' => $language->slug === app()->getLocale()])>
{{ $language->name }}
</a>
@endforeach
request()->route()->parameters() carries through route-model-bound IDs; the ['locale' => ...] merge overwrites just the locale.
Persist the choice across sessions. Three options, in increasing fidelity: session key (fragile, breaks in API/queue), cookie (works for guests), user column (correct for logged-in users). Do it in the switch route, not on every request:
Route::get('language/{locale}', function ($locale) {
abort_unless(Language::where('slug', $locale)->exists(), 404);
session(['locale' => $locale]);
if (auth()->check()) {
auth()->user()->update(['locale' => $locale]);
}
return redirect()->back();
})->name('language.switch');
Then, in the locale-detection middleware, order the resolution explicitly: route parameter → authenticated user → session → Accept-Language → default. Write that chain down in a comment; it's the thing people argue about in code review.
Localized content: pick a storage model on day one. Two viable shapes and you must not mix them:
// (a) column-per-locale — simple, breaks down past ~3 locales or long text
$products = Product::select('id', "name_{$locale} as name", "description_{$locale} as description")->get();
// (b) translations table — the scalable one, FK back to languages.slug
$product->translations()->where('locale', app()->getLocale())->first()?->name;
With (b), add a Product::translated() scope plus a withTranslation() eager-load and use fallback_locale for missing rows — never return a blank title because a translator was late.
SEO plumbing is mandatory, not optional. Emit hreflang alternates and a canonical for every localized page:
@foreach ($languages as $language)
<link rel="alternate" hreflang="{{ $language->slug }}"
href="{{ lroute(Route::currentRouteName(), array_merge(request()->route()->parameters(), ['locale' => $language->slug])) }}">
@endforeach
<link rel="canonical" href="{{ url()->current() }}">
Background work needs explicit locale. Queued jobs, mailables, and notifications do not inherit the request locale. Pass it as a constructor argument and App::setLocale($this->locale) at the top of handle() / build().
API variant. For JSON endpoints, skip the URL prefix entirely and resolve from Accept-Language in a separate middleware, then reuse the same Language table for validation. Mixing the two — prefix and header — is a reliable source of "why is my de request returning Italian" bugs.
Str::slug, Arr::get, and the removal of the str_slug()/array_get() helpers in Laravel 6. If you're on 6+, expect Call to undefined function str_slug() on boot and a handful of type-hint mismatches. The fork is typically 10–20 lines in one or two files. Budget an afternoon, not a day.config/app.php is not enough if you also use config:cache — run php artisan config:clear after editing, or you'll debug a middleware that "isn't registered" for an hour.web (which includes StartSession) must run before your locale middleware. In $middlewareGroups['web'], put 'locale' at the end of the array, not the beginning.Language::where('slug', ...) in middleware is one query per request. Cache the slug list and the language collection forever, and forget the cache in the Language model's saved/deleted events.en/it are fine; pt-BR, zh_CN, en_GB are not URL-safe habits. Keep a locale column separate from slug and set app()->setLocale($language->locale), not the slug. Doing this late means renaming URLs and losing links./ redirects 301 to /en/, or /en/* redirects 301 to /*. Never serve both 200.php artisan route:cache will fail if you used closure routes in the localized group — move them to controllers before deploying, and re-run route:cache after any locale add.config/app.php fallback_locale should equal your default language slug. The package's own fallback and Laravel's are two different mechanisms; setting both to the same value avoids the "some strings translated, some not, differently" bug.app()->getLocale(), not by the route segment. If the slug is de but the locale is de_DE, you need resources/lang/de_DE/. Mismatch here silently renders the fallback language with no error.route('name') without locale throws. That's a feature — it surfaces every unlocalized link at test time rather than in production. Add a smoke test that crawls your nav and asserts no UrlGenerationException.$this->get('/it/prodotti')->assertSee('Prodotti'); $this->assertEquals('it', app()->getLocale()); — and reset App::setLocale() in tearDown(), because the locale leaks between tests in the same process.Language model subclass bound in a service provider, and a lroute() helper. All three survive a composer update; patching src/ does not.<html lang="{{ app()->getLocale() }}"> and dir="rtl" for ar/he. Add them in the layout the same day you add the first non-Latin language — retrofitting RTL into a shipped CSS is the expensive version of this task.How can I help you explore Laravel packages today?