longman/laravel-multilang
A Laravel package for building multilingual sites with localized routes, language switching, and translation helpers. It simplifies managing URL slugs and locale-aware content, making it easy to serve users in multiple languages.
Install and publish
composer require longman/laravel-multilang
php artisan vendor:publish --provider="Longman\LaravelMultiLang\MultiLangServiceProvider"
php artisan migrate
On Laravel 5.5+ the service provider is auto-discovered; on older versions add it (and the MultiLang facade/alias) to config/app.php. Publishing gives you config/multilang.php plus the languages (and translation) migrations.
Seed the languages table first. Nothing works until there is at least one row and exactly one marked as default. Treat rows as the source of truth for what the app offers: code (en, ru, de), display name, enabled, default flag. Seed via a seeder so fresh installs and CI are reproducible rather than editing rows by hand.
Register the middleware in the web group (or on the localized route group) before anything that reads app()->getLocale(). This is what resolves the active language from route/session/cookie/browser and calls App::setLocale().
First use case — a language switcher. Add a route group with a {lang} prefix, expose MultiLang::getLanguages() (and getLang() / getDefaultLang()) to a small Blade partial, and render one link per language pointing at the same path with the prefix swapped. That single loop is 80% of what teams actually use this package for.
Where to look first: config/multilang.php (precedence, keys, default language, URL behaviour), the languages table contents, then the package middleware. If something is "not switching," it is almost always config or middleware ordering, not the package.
URL-prefixed routing as the backbone. The most reliable pattern is a localized route group with an explicit constraint:
Route::group(['prefix' => '{lang}', 'middleware' => 'multilang',
'where' => ['lang' => '[a-z]{2}']], function () {
Route::get('/', 'HomeController@index')->name('home');
// ...
});
The where constraint is not optional in practice — without it the prefix swallows unrelated first segments. Keep the default language on the same prefixed URL and issue a redirect from /, rather than maintaining two parallel route trees.
One resolver, one precedence order. Decide once, write it down, and never special-case it: route segment → session → cookie → Accept-Language → default. Put session/cookie persistence in a tiny listener or the middleware itself, and make the switcher write the choice through the same path. Ad-hoc Session::put('lang', ...) sprinkled across controllers is how you end up with a locale that disagrees with the URL.
Link generation helper. route() knows nothing about your prefix. Add one helper and use it everywhere:
function lroute(string $name, array $params = [], ?string $lang = null): string
{
return route($name, ['lang' => $lang ?? MultiLang::getLang()] + $params);
}
Alternatively set URL::defaults(['lang' => MultiLang::getLang()]) in the middleware so route() calls without an explicit lang just work. Pick one; mixing both produces confusing bugs.
Switcher partial. Build the target URL from the current request path, replacing the leading segment, and carry the query string across:
@foreach (MultiLang::getLanguages() as $language)
<a href="{{ switch_url($language->code) }}"
@class(['active' => $language->code === MultiLang::getLang()])>
{{ $language->name }}
</a>
@endforeach
Keep the switcher on every page, including 404s, so users never get stranded.
Translations: files for UI, DB for content. Keep static interface strings in resources/lang/{locale} so they go through normal Laravel tooling and are greppable in code review. Use the package's DB-backed translations for strings that product/copy teams edit at runtime, and be disciplined about the key namespace (home.hero.title, not title). Never put the same key in both places — you will spend an afternoon debugging which one won.
Localized models. Don't bolt title_en, title_ru columns onto tables. Either a *_translations side table with a locale column, or a JSON column with a cast:
protected $casts = ['title' => 'array']; // ['en' => '...', 'ru' => '...']
public function t(string $field): string {
return $this->{$field}[app()->getLocale()] ?? $this->{$field}[config('multilang.default_lang')];
}
The fallback branch is the whole point — content authored in one language should never render blank.
Per-request locale, per-job locale. Requests get the locale for free from the middleware; queued jobs and console commands do not. Set App::setLocale($this->locale) at the top of handle() and pass the locale into the job constructor. Same for mailables and notification classes — set the locale before rendering.
API routes stay unprefixed. Don't drag the language middleware onto api.php. Accept Accept-Language or a ?lang= query and resolve explicitly, otherwise cached responses get poisoned across locales.
SEO plumbing. Emit <link rel="alternate" hreflang="..."> for every enabled language plus x-default, and keep a canonical URL per locale. Per-locale sitemaps are cheap to generate from the languages table and pay off immediately.
Cache the language list. Read the languages table once per process (or cache it with a short TTL and bust on write). A DB query for the switcher on every request is a waste.
app()->getLocale() — including view composers, View::share(), global scopes that filter localized content, and URL defaults. Registering it deep in the web stack is a classic source of "the first page is always English."/ and redirect / → /{default}, and also define routes under {lang}, make sure /en/... doesn't redirect to /en/en/.... Guard the redirect with a check that the target isn't the current URL./de/foo, their cookie says en. Whichever wins must win consistently — and the switcher should update the cookie so the two converge. Assert this in a test.config:cache and stale languages. If you cache the languages collection in config or in a cached config file, adding a language requires a cache clear. Cache it in the cache store, not in config, and expose an invalidation point.{lang} in a prefix plus closures is fine, but named-route collisions (home defined once) mean you must always supply lang when generating URLs. Verify with php artisan route:list after adding languages — a missing constraint shows up as routes eating /admin.languages table, publish and edit the migration before running, or the migrate step fails halfway. Do this before the first deploy, not after.Str/Arr helper usage and PHP version constraints before adopting. With a small user base, the source is the documentation — read the middleware and service provider; they're short and explain the precedence rules better than the README.app()->getLocale() and the resolved language source (route/session/cookie/browser/default) in the middleware temporarily. Nine out of ten "wrong language" reports are the browser header silently winning because session persistence wasn't wired.$this->withSession(['lang' => 'de']) or hit the prefixed URL) rather than relying on the test client's headers. Add one test that asserts each language resolves and one that asserts the fallback renders the default language's content.languages model can be swapped via config/binding if you need extra columns; the middleware is small enough to subclass for custom precedence; and config publishing lets you override keys without touching vendor code. Prefer those three over forking — you'll want to pull upstream fixes.How can I help you explore Laravel packages today?