Building a Multi-Language Blog with Laravel Localization
Running a blog in multiple languages isn't just about translating strings. It's about routing, content structure, SEO signals, and a seamless reader experience. This guide covers every layer — from file organization to hreflang tags — based on a real production blog serving English and Vietnamese content.
Architecture Decision: File-Based vs Database
For a Markdown-first blog, file-based localization wins:
content/
posts/
en/
2026/04/27-multilanguage-laravel-localization.md
vi/
2026/04/27-multilanguage-laravel-localization.md
pages/
en/
about.md
vi/
about.md
Why file-based?
- Markdown stays the single source of truth
- Git tracks all translations with full history
- No database dependency for content
- Easy to diff translations side-by-side
- Deploys are atomic — content and code ship together
Route-Based Locale Switching
Route Structure
// routes/web.php
Route::prefix('{locale}')
->where(['locale' => 'en|vi'])
->middleware('set.locale')
->group(function () {
Route::get('/blog', [BlogController::class, 'index'])->name('blog.index');
Route::get('/blog/{slug}', [BlogController::class, 'show'])->name('blog.show');
Route::get('/tags/{tag}', [BlogController::class, 'tag'])->name('blog.tag');
Route::get('/page/{slug}', [PageController::class, 'show'])->name('page.show');
});
// Redirect root to default locale
Route::get('/', function () {
$locale = request()->getPreferredLanguage(['en', 'vi']) ?? 'en';
return redirect("/{$locale}/blog");
});
URLs become:
/en/blog/multilanguage-laravel-localization
/vi/blog/multilanguage-laravel-localization
Locale Middleware
// app/Http/Middleware/SetLocale.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
class SetLocale
{
private const SUPPORTED_LOCALES = ['en', 'vi'];
public function handle(Request $request, Closure $next)
{
$locale = $request->route('locale', 'en');
if (!in_array($locale, self::SUPPORTED_LOCALES, true)) {
abort(404);
}
App::setLocale($locale);
carbon()->setLocale($locale);
return $next($request);
}
}
Register in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'set.locale' => \App\Http\Middleware\SetLocale::class,
]);
})
Loading Localized Markdown Content
MarkdownPostService Updates
// app/Services/MarkdownPostService.php
class MarkdownPostService
{
public function __construct(
private MarkdownConverter $converter,
) {}
public function getAllPosts(?string $locale = null): Collection
{
$locale ??= app()->getLocale();
$path = content_path("posts/{$locale}");
return $this->loadPostsFromPath($path)
->filter(fn (array $post) => !($post['draft'] ?? false))
->sortByDesc('date')
->values();
}
public function getPost(string $slug, ?string $locale = null): ?array
{
$locale ??= app()->getLocale();
$cacheKey = "post_{$locale}_{$slug}";
return cache()->remember($cacheKey, now()->addDay(), function () use ($slug, $locale) {
$post = $this->findPostBySlug($slug, $locale);
if (!$post) {
return null;
}
return $this->parsePost($post);
});
}
public function getAvailableLocales(string $slug): array
{
$locales = [];
foreach (['en', 'vi'] as $locale) {
if ($this->findPostBySlug($slug, $locale)) {
$locales[] = $locale;
}
}
return $locales;
}
private function findPostBySlug(string $slug, string $locale): ?string
{
$pattern = content_path("posts/{$locale}/**/*-{$slug}.md");
$files = glob($pattern);
return $files[0] ?? null;
}
}
Cache Key Strategy
Include locale in every cache key to avoid serving wrong language:
// Good
"post_{$locale}_{$slug}" // post_vi_laravel-caching
"posts_list_{$locale}" // posts_list_en
"tags_{$locale}" // tags_vi
// Bad — will mix languages
"post_{$slug}"
"posts_list"
Translation Files for UI Strings
Structure
lang/
en/
messages.php
blog.php
vi/
messages.php
blog.php
Blog Translation File
// lang/en/blog.php
return [
'title' => 'Blog',
'read_more' => 'Read more',
'published_on' => 'Published on :date',
'reading_time' => ':minutes min read',
'tags' => 'Tags',
'related_posts' => 'Related Posts',
'no_posts' => 'No posts found.',
'search_placeholder' => 'Search articles...',
'all_tags' => 'All Tags',
'newer_posts' => 'Newer Posts',
'older_posts' => 'Older Posts',
'table_of_contents' => 'Table of Contents',
'share' => 'Share this article',
'also_available_in' => 'Also available in :language',
'draft_notice' => 'This post is a draft and not publicly visible.',
];
// lang/vi/blog.php
return [
'title' => 'Blog',
'read_more' => 'Đọc thêm',
'published_on' => 'Đăng ngày :date',
'reading_time' => ':minutes phút đọc',
'tags' => 'Thẻ',
'related_posts' => 'Bài Viết Liên Quan',
'no_posts' => 'Không tìm thấy bài viết.',
'search_placeholder' => 'Tìm kiếm bài viết...',
'all_tags' => 'Tất Cả Thẻ',
'newer_posts' => 'Bài Mới Hơn',
'older_posts' => 'Bài Cũ Hơn',
'table_of_contents' => 'Mục Lục',
'share' => 'Chia sẻ bài viết',
'also_available_in' => 'Cũng có bản :language',
'draft_notice' => 'Bài viết này là bản nháp và không hiển thị công khai.',
];
Blade Views with Localization
Language Switcher Component
// resources/views/components/language-switcher.blade.php
@props(['slug' => null, 'availableLocales' => ['en', 'vi']])
@php
$currentLocale = app()->getLocale();
$localeNames = [
'en' => 'English',
'vi' => 'Tiếng Việt',
];
@endphp
<div class="flex items-center gap-2 text-sm">
@foreach ($availableLocales as $locale)
@if ($locale === $currentLocale)
<span class="font-bold text-indigo-600 dark:text-indigo-400">
{{ $localeNames[$locale] }}
</span>
@else
<a href="{{ route(Route::currentRouteName(), array_merge(
Route::current()->parameters(),
['locale' => $locale]
)) }}"
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400 transition"
hreflang="{{ $locale }}">
{{ $localeNames[$locale] }}
</a>
@endif
@unless ($loop->last)
<span class="text-gray-300 dark:text-gray-600">|</span>
@endunless
@endforeach
</div>
Post Listing with Translations
{{-- resources/views/blog/index.blade.php --}}
<h1>{{ __('blog.title') }}</h1>
@forelse ($posts as $post)
<article class="mb-8">
<h2>
<a href="{{ route('blog.show', ['locale' => app()->getLocale(), 'slug' => $post['slug']]) }}">
{{ $post['title'] }}
</a>
</h2>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ __('blog.published_on', ['date' => $post['date']->translatedFormat('F j, Y')]) }}
·
{{ __('blog.reading_time', ['minutes' => $post['reading_time']]) }}
</div>
<p class="mt-2">{{ $post['description'] }}</p>
<a href="{{ route('blog.show', ['locale' => app()->getLocale(), 'slug' => $post['slug']]) }}"
class="text-indigo-600 dark:text-indigo-400">
{{ __('blog.read_more') }} →
</a>
</article>
@empty
<p>{{ __('blog.no_posts') }}</p>
@endforelse
Localized Date Display
Carbon supports locale-aware formatting out of the box:
// In middleware, we already set:
carbon()->setLocale($locale);
// In Blade:
{{ $post['date']->translatedFormat('j F, Y') }}
// English: April 27, 2026
// Vietnamese: 27 tháng 4, 2026
SEO: hreflang and Canonical Tags
Search engines need explicit signals about language variants. This is the most commonly missed piece.
Hreflang Meta Tags
{{-- resources/views/layouts/blog.blade.php --}}
<head>
{{-- Canonical for current page --}}
<link rel="canonical" href="{{ url()->current() }}">
{{-- hreflang alternates --}}
@isset($availableLocales)
@foreach ($availableLocales as $locale)
<link rel="alternate"
hreflang="{{ $locale }}"
href="{{ route(Route::currentRouteName(), array_merge(
Route::current()->parameters(),
['locale' => $locale]
)) }}">
@endforeach
{{-- x-default for language selection page --}}
<link rel="alternate"
hreflang="x-default"
href="{{ route(Route::currentRouteName(), array_merge(
Route::current()->parameters(),
['locale' => 'en']
)) }}">
@endisset
</head>
Open Graph Locale Tags
<meta property="og:locale" content="{{ app()->getLocale() === 'vi' ? 'vi_VN' : 'en_US' }}">
@foreach ($availableLocales as $locale)
@if ($locale !== app()->getLocale())
<meta property="og:locale:alternate"
content="{{ $locale === 'vi' ? 'vi_VN' : 'en_US' }}">
@endif
@endforeach
JSON-LD Structured Data with Language
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "{{ $post['title'] }}",
"inLanguage": "{{ app()->getLocale() }}",
"datePublished": "{{ $post['date']->toIso8601String() }}",
"description": "{{ $post['description'] }}",
"author": {
"@type": "Person",
"name": "Your Name"
},
"isPartOf": {
"@type": "Blog",
"name": "Your Blog",
"inLanguage": ["en", "vi"]
}
@if (count($availableLocales) > 1)
,"workTranslation": [
@foreach ($availableLocales as $locale)
@if ($locale !== app()->getLocale())
{
"@type": "BlogPosting",
"inLanguage": "{{ $locale }}",
"url": "{{ route('blog.show', ['locale' => $locale, 'slug' => $post['slug']]) }}"
}
@endif
@endforeach
]
@endif
}
</script>
Localized RSS Feeds
Generate separate feeds per language:
// routes/web.php
Route::get('/{locale}/feed.xml', [FeedController::class, 'index'])
->where('locale', 'en|vi')
->name('feed');
// app/Http/Controllers/FeedController.php
public function index(string $locale)
{
App::setLocale($locale);
$posts = $this->postService->getAllPosts($locale)->take(20);
return response()
->view('feeds.rss', [
'posts' => $posts,
'locale' => $locale,
])
->header('Content-Type', 'application/xml');
}
{{-- resources/views/feeds/rss.blade.php --}}
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Your Blog ({{ strtoupper($locale) }})</title>
<link>{{ url("/{$locale}/blog") }}</link>
<language>{{ $locale }}</language>
<atom:link href="{{ route('feed', $locale) }}" rel="self" type="application/rss+xml"/>
@foreach ($posts as $post)
<item>
<title>{{ htmlspecialchars($post['title'], ENT_XML1) }}</title>
<link>{{ route('blog.show', ['locale' => $locale, 'slug' => $post['slug']]) }}</link>
<description>{{ htmlspecialchars($post['description'], ENT_XML1) }}</description>
<pubDate>{{ $post['date']->toRfc2822String() }}</pubDate>
<guid>{{ route('blog.show', ['locale' => $locale, 'slug' => $post['slug']]) }}</guid>
</item>
@endforeach
</channel>
</rss>
Localized Sitemap
// routes/web.php
Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap');
public function index()
{
$urls = collect();
foreach (['en', 'vi'] as $locale) {
$posts = $this->postService->getAllPosts($locale);
foreach ($posts as $post) {
$url = route('blog.show', ['locale' => $locale, 'slug' => $post['slug']]);
$alternates = [];
foreach (['en', 'vi'] as $altLocale) {
if ($this->postService->findPostBySlug($post['slug'], $altLocale)) {
$alternates[$altLocale] = route('blog.show', [
'locale' => $altLocale,
'slug' => $post['slug'],
]);
}
}
$urls->push([
'url' => $url,
'lastmod' => $post['updated_at'] ?? $post['date'],
'alternates' => $alternates,
]);
}
}
return response()
->view('feeds.sitemap', ['urls' => $urls])
->header('Content-Type', 'application/xml');
}
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
@foreach ($urls as $entry)
<url>
<loc>{{ $entry['url'] }}</loc>
<lastmod>{{ $entry['lastmod']->toDateString() }}</lastmod>
@foreach ($entry['alternates'] as $locale => $href)
<xhtml:link rel="alternate" hreflang="{{ $locale }}" href="{{ $href }}"/>
@endforeach
</url>
@endforeach
</urlset>
Browser Language Detection
Auto-redirect first-time visitors based on their browser language:
// Redirect root based on Accept-Language
Route::get('/', function () {
$preferred = request()->getPreferredLanguage(['en', 'vi']);
// Check cookie for returning visitors
$saved = request()->cookie('preferred_locale');
if ($saved && in_array($saved, ['en', 'vi'], true)) {
return redirect("/{$saved}/blog");
}
return redirect("/{$preferred}/blog")
->cookie('preferred_locale', $preferred, 60 * 24 * 365);
});
Testing Localized Routes
// tests/Feature/LocalizationTest.php
namespace Tests\Feature;
use Tests\TestCase;
class LocalizationTest extends TestCase
{
public function test_english_blog_listing(): void
{
$response = $this->get('/en/blog');
$response->assertStatus(200);
$response->assertSee('Read more');
}
public function test_vietnamese_blog_listing(): void
{
$response = $this->get('/vi/blog');
$response->assertStatus(200);
$response->assertSee('Đọc thêm');
}
public function test_invalid_locale_returns_404(): void
{
$response = $this->get('/fr/blog');
$response->assertStatus(404);
}
public function test_post_available_in_both_locales(): void
{
$response = $this->get('/en/blog/laravel-caching');
$response->assertStatus(200);
$response = $this->get('/vi/blog/laravel-caching');
$response->assertStatus(200);
}
public function test_hreflang_tags_present(): void
{
$response = $this->get('/en/blog/laravel-caching');
$response->assertSee('hreflang="en"', false);
$response->assertSee('hreflang="vi"', false);
$response->assertSee('hreflang="x-default"', false);
}
public function test_root_redirects_to_locale(): void
{
$response = $this->get('/');
$response->assertRedirect();
$this->assertMatchesRegularExpression('#/(en|vi)/blog#', $response->headers->get('Location'));
}
public function test_locale_cache_keys_are_separate(): void
{
$this->get('/en/blog/laravel-caching');
$this->assertTrue(cache()->has('post_en_laravel-caching'));
$this->get('/vi/blog/laravel-caching');
$this->assertTrue(cache()->has('post_vi_laravel-caching'));
}
}
Common Pitfalls
1. Forgetting Locale in Generated URLs
// Wrong — loses locale
route('blog.show', ['slug' => $post['slug']])
// Right — always include locale
route('blog.show', ['locale' => app()->getLocale(), 'slug' => $post['slug']])
2. Cache Poisoning Between Languages
// Wrong — same cache key for all locales
cache()->remember("post_{$slug}", ...);
// Right
cache()->remember("post_{$locale}_{$slug}", ...);
3. Hardcoded Strings in Blade
{{-- Wrong --}}
<h1>Blog</h1>
<span>5 min read</span>
{{-- Right --}}
<h1>{{ __('blog.title') }}</h1>
<span>{{ __('blog.reading_time', ['minutes' => 5]) }}</span>
4. Missing hreflang x-default
Google needs x-default to know which version to show when the user's language doesn't match any variant:
<link rel="alternate" hreflang="x-default" href="https://blog.example.com/en/blog/post-slug">
5. Not Translating Meta Descriptions
{{-- Every post's frontmatter should have a translated description --}}
<meta name="description" content="{{ $post['description'] }}">
Adding a New Language
When you're ready to add a third language (e.g., Japanese):
- Create content directory:
content/posts/ja/ - Add translation file:
lang/ja/blog.php - Update locale list in middleware, routes, and sitemap
- Translate existing posts (or mark them as draft until translated)
// config/app.php
'available_locales' => ['en', 'vi', 'ja'],
// Use this config everywhere instead of hardcoding
$locales = config('app.available_locales');
Summary
| Layer | What to localize |
|---|---|
| Content | Separate Markdown files per locale |
| Routes | /{locale}/blog/{slug} prefix pattern |
| UI Strings | lang/{locale}/blog.php translation files |
| Dates | Carbon::translatedFormat() |
| SEO | hreflang, OG locale, JSON-LD inLanguage |
| Feeds | Per-locale RSS and sitemap with alternates |
| Cache | Locale-prefixed cache keys |
The key insight: localization is not a feature you bolt on later. It touches routing, caching, SEO, and content structure. Design for it from the start, even if you launch with one language.