Xây Dựng Blog Đa Ngôn Ngữ Với Laravel Localization

· 15 min read

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')]) }}
            &middot;
            {{ __('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):

  1. Tạo thư mục nội dung: content/posts/ja/
  2. Thêm file dịch: lang/ja/blog.php
  3. Cập nhật danh sách locale trong middleware, routes, và sitemap
  4. 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.

Bình luận