Xây Dựng Blog Đa Ngôn Ngữ Với Laravel Localization
Chạy blog đa ngôn ngữ không chỉ là dịch string. Nó liên quan đến routing, cấu trúc nội dung, SEO, và trải nghiệm đọc mượt mà. Bài viết này bao quát mọi layer — từ tổ chức file đến hreflang tags — dựa trên blog production thực tế phục vụ nội dung Tiếng Anh và Tiếng Việt.
Quyết Định Kiến Trúc: File-Based vs Database
Với blog Markdown-first, file-based localization là lựa chọn tối ưu:
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
Tại sao file-based?
- Markdown là single source of truth
- Git theo dõi toàn bộ bản dịch với lịch sử đầy đủ
- Không phụ thuộc database cho nội dung
- Dễ dàng diff bản dịch song song
- Deploy nguyên tử — nội dung và code ship cùng nhau
Route-Based Locale Switching
Cấu Trúc Route
// 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 về locale mặc định
Route::get('/', function () {
$locale = request()->getPreferredLanguage(['en', 'vi']) ?? 'en';
return redirect("/{$locale}/blog");
});
URL sẽ trở thành:
/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);
}
}
Đăng ký trong bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'set.locale' => \App\Http\Middleware\SetLocale::class,
]);
})
Load Nội Dung Markdown Theo Ngôn Ngữ
Cập Nhật MarkdownPostService
// 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;
}
}
Chiến Lược Cache Key
Luôn bao gồm locale trong mọi cache key để tránh phục vụ sai ngôn ngữ:
// Đúng
"post_{$locale}_{$slug}" // post_vi_laravel-caching
"posts_list_{$locale}" // posts_list_en
"tags_{$locale}" // tags_vi
// Sai — sẽ trộn lẫn ngôn ngữ
"post_{$slug}"
"posts_list"
File Dịch Cho UI Strings
Cấu Trúc
lang/
en/
messages.php
blog.php
vi/
messages.php
blog.php
File Dịch Blog
// 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 Với Localization
Component Chuyển Ngôn Ngữ
// 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>
Danh Sách Bài Viết Với Bản Dịch
{{-- 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
Hiển Thị Ngày Theo Ngôn Ngữ
Carbon hỗ trợ format theo locale sẵn:
// Trong middleware, đã set:
carbon()->setLocale($locale);
// Trong Blade:
{{ $post['date']->translatedFormat('j F, Y') }}
// English: April 27, 2026
// Vietnamese: 27 tháng 4, 2026
SEO: hreflang và Canonical Tags
Các công cụ tìm kiếm cần tín hiệu rõ ràng về các phiên bản ngôn ngữ. Đây là phần hay bị bỏ quên nhất.
Thẻ Meta hreflang
{{-- resources/views/layouts/blog.blade.php --}}
<head>
{{-- Canonical cho trang hiện tại --}}
<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 cho trang chọn ngôn ngữ --}}
<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 Với Ngôn Ngữ
<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>
RSS Feed Theo Ngôn Ngữ
Tạo feed riêng cho mỗi ngôn ngữ:
// 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>
Sitemap Đa Ngôn Ngữ
// 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>
Phát Hiện Ngôn Ngữ Trình Duyệt
Tự động redirect người dùng lần đầu dựa trên ngôn ngữ trình duyệt:
// Redirect root dựa trên Accept-Language
Route::get('/', function () {
$preferred = request()->getPreferredLanguage(['en', 'vi']);
// Kiểm tra cookie cho người dùng quay lại
$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);
});
Kiểm Thử Route Đa Ngôn Ngữ
// 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'));
}
}
Lỗi Thường Gặp
1. Quên Locale Khi Tạo URL
// Sai — mất locale
route('blog.show', ['slug' => $post['slug']])
// Đúng — luôn bao gồm locale
route('blog.show', ['locale' => app()->getLocale(), 'slug' => $post['slug']])
2. Cache Bị Nhiễm Chéo Ngôn Ngữ
// Sai — cùng cache key cho mọi locale
cache()->remember("post_{$slug}", ...);
// Đúng
cache()->remember("post_{$locale}_{$slug}", ...);
3. String Cứng Trong Blade
{{-- Sai --}}
<h1>Blog</h1>
<span>5 min read</span>
{{-- Đúng --}}
<h1>{{ __('blog.title') }}</h1>
<span>{{ __('blog.reading_time', ['minutes' => 5]) }}</span>
4. Thiếu hreflang x-default
Google cần x-default để biết hiển thị phiên bản nào khi ngôn ngữ người dùng không khớp:
<link rel="alternate" hreflang="x-default" href="https://blog.example.com/en/blog/post-slug">
5. Không Dịch Meta Description
{{-- Mỗi bài viết cần description đã được dịch trong frontmatter --}}
<meta name="description" content="{{ $post['description'] }}">
Thêm Ngôn Ngữ Mới
Khi bạn muốn thêm ngôn ngữ thứ ba (ví dụ: Tiếng Nhật):
- Tạo thư mục nội dung:
content/posts/ja/ - Thêm file dịch:
lang/ja/blog.php - Cập nhật danh sách locale trong middleware, routes, và sitemap
- Dịch bài viết hiện có (hoặc đánh dấu draft cho đến khi dịch xong)
// config/app.php
'available_locales' => ['en', 'vi', 'ja'],
// Dùng config này ở mọi nơi thay vì hardcode
$locales = config('app.available_locales');
Tổng Kết
| Layer | Cần localize gì |
|---|---|
| Nội dung | File Markdown riêng cho mỗi locale |
| Routes | Pattern prefix /{locale}/blog/{slug} |
| UI Strings | File dịch lang/{locale}/blog.php |
| Ngày tháng | Carbon::translatedFormat() |
| SEO | hreflang, OG locale, JSON-LD inLanguage |
| Feeds | RSS và sitemap riêng từng locale với alternates |
| Cache | Cache key có prefix locale |
Điều quan trọng nhất: localization không phải feature gắn thêm sau. Nó ảnh hưởng đến routing, caching, SEO, và cấu trúc nội dung. Hãy thiết kế cho nó ngay từ đầu, kể cả khi bạn launch với một ngôn ngữ duy nhất.