Building a Multi-Language Blog with Laravel Localization

· 13 min read

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')]) }}
            &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

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):

  1. Create content directory: content/posts/ja/
  2. Add translation file: lang/ja/blog.php
  3. Update locale list in middleware, routes, and sitemap
  4. 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.

Comments