The standard Laravel path: lang/ru/messages.php, lang/en/messages.php, __('messages.hero_title') calls in the templates. For a site with hundreds of strings and a dedicated translator that is right. For a one-screen landing page and a two-post blog it is an extra layer that turns editing a single sentence into hopping between three files.
I wired bilingual support differently, and half a year of edits later I do not regret it. Here is the whole design, including the trap that cost me half an hour.
The language lives in the route, not the session
No Accept-Language sniffing and no locale in the session. The root serves Russian, English lives under a slug:
Route::get('/', [LandingController::class, 'index']);
Route::get('/en', [LandingController::class, 'index'])->defaults('lang', 'en');
It looks primitive, but that primitiveness is exactly what solves the classic bilingual problem: redirect loops. When the language sits in the session and the root redirects by browser header, the switcher starts fighting the auto-detection. The user clicks "RU", lands on the root, the root sees an English browser and sends them back to /en. That cannot happen here: the switcher is two plain links, and each goes exactly where it says.
The trap: defaults does not work on a group
The blog needs the same routes under a prefix. You write the obvious thing:
// does not work
Route::prefix('en')->defaults('lang', 'en')->group(function () {
Route::get('/blog', [BlogController::class, 'index']);
});
And you get Method Illuminate\Routing\RouteRegistrar::defaults does not exist. A route has defaults(), the group registrar does not: prefix, middleware and name chain onto a group, default values do not. Each route needs its own:
Route::prefix('en')->group(function () {
Route::get('/blog', [BlogController::class, 'index'])
->defaults('lang', 'en')->name('en.blog.index');
});
The extra nastiness: the error surfaces on the first request rather than while routes load, so it gets lost among everything else in the log.
Text lives in three places, on purpose
Landing copy is a constant in the controller. One array keyed by ru and en, both sets side by side:
private const TRANSLATIONS = [
'ru' => ['hero_offer' => 'Делаю сайты, веб-приложения и API...'],
'en' => ['hero_offer' => 'I build websites, web apps and APIs...'],
];
Rewording something means opening one file and seeing both versions at once. With lang files you would keep two open.
Shared strings - header, footer, blog labels - moved into their own class. When the blog got the same header as the landing, the temptation was to copy the keys over. That would have created two sources for one string and a guaranteed drift within a month. Instead there is SiteText::for($lang), and the duplicates left the landing array.
Article text lives in the database as field pairs: title and title_en, content and content_en. Russian is required, English is not.
Fall back instead of showing nothing
An article is almost always written in Russian first. If the translation is not ready, the English page must not be empty:
public function translatedTitle(): string
{
return ($this->isEn() && $this->title_en) ? $this->title_en : $this->title;
}
The check is for a non-empty value rather than a present field: an empty string in the database also means "no translation".
One side effect worth knowing up front: /en may show Russian text. For a blog that beats a hole in the page, but you must not emit hreflang for such a post - otherwise the engine gets promised an English version that does not exist. Mine prints the alternates only when title_en is filled.
The application locale has to be set by hand
The model decides which set of fields to return based on app()->getLocale(). But the route value never gets there on its own - Laravel knows nothing about our lang parameter. So the blog controller does it explicitly:
private function lang(Request $request): string
{
$lang = $request->route('lang') === 'en' ? 'en' : 'ru';
app()->setLocale($lang);
return $lang;
}
The landing does not need this: it reads its copy directly by $lang without asking about the locale. A small difference, but easy to forget, and then the English article silently serves Russian text.
A switcher that does not lose the page
The most common omission on bilingual sites: from /en/blog/some-post the "RU" button drops you on the home page. The reader was mid-article and ended up on the landing.
The fix is letting every page declare its own alternates. In the shared layout the switcher pulls URLs from sections with a fallback:
<a href="@yield('alt-ru', url('/'))">RU</a>
<a href="@yield('alt-en', url('/en'))">EN</a>
And the article page overrides them:
@section('alt-ru', url('/blog/'.$post->slug))
@section('alt-en', url('/en/blog/'.$post->slug))
The landing overrides nothing and uses the fallbacks. Three lines, and the behaviour is correct on every page.
What the search engine should see
Every version needs a canonical pointing at itself - otherwise one of them declares itself a copy of the other and drops out of the results. Plus mutual hreflang and an x-default on the Russian one:
<link rel="canonical" href="https://site.com/en/blog/slug">
<link rel="alternate" hreflang="ru" href="https://site.com/blog/slug">
<link rel="alternate" hreflang="en" href="https://site.com/en/blog/slug">
<link rel="alternate" hreflang="x-default" href="https://site.com/blog/slug">
The sitemap is generated dynamically and lists both versions of every page, each entry carrying the same alternates as xhtml:link. Articles land there automatically.
What has to stay in sync
The weak spot of this approach is obvious: a forgotten key in the English set takes down /en with an "undefined array key" error, and you may only notice in production. Over one editing session I checked the two key sets by hand about six times, then closed the question with a test:
$this->assertSame([], array_values(array_diff(array_keys($ru), array_keys($en))),
'The English set is missing keys');
The same test catches empty values and forbidden characters. Eighty-five keys, two locales, four checks - and a whole class of bugs is gone before the code leaves the machine.
When not to do this
If a separate person handles translation, if you have more than two languages, or more than a couple of hundred strings - use lang files. That is what they are for: you hand the translator a folder, not your controller source.
Everything else - a landing page, a portfolio, a small corporate site where the same person edits both the copy and the markup - is better off with an array in the controller. Fewer files, less indirection, and both versions always in front of you.