Building a multilingual site with Laravel Localization

This is a step by step recipe for installing and configuring the Laravel Localization package in a Twill project.

Objectives:

  • Configure Twill for multilingual content management
  • Install and configure Laravel Localization
  • Create an 'articles' module and build a simple frontend for it

Configure Twill

In this example, we'll be configuring a site in English and French. Out of the box, Twill is configured for English by default. Let's add French as a secondary language:

config/translatable.php

1'locales' => [
2 'en',
3 'fr',
4],

Create the articles module

Make sure to enable translations and slugs. The rest is entirely optional:

1php artisan twill:make:module -TS articles

For simplicity, keep the default title and description fields.

Then, run the migrations, add the module to routes/admin.php and to twill-navigation.php.

Create your content

On the Twill side, nothing else is needed. When creating an article, you can edit your content in both languages using the language selector. After editing a record, make sure to mark all languages as "Live":

01_live_languages

Install Laravel Localization

Install the package in your project via composer:

1composer require mcamara/laravel-localization

Then publish the configuration file:

1php artisan vendor:publish --provider="Mcamara\LaravelLocalization\LaravelLocalizationServiceProvider"

This will create the file: config/laravellocalization.php

Configure Laravel Localization

Like Twill, the package is configured for English by default. A large number of languages are made available in the supportedLocales array. Let's uncomment the line for French:

config/laravellocalization.php

1'supportedLocales' => [
2 'en' => ['name' => 'English', 'script' => 'Latn', 'native' => 'English', 'regional' => 'en_GB'],
3 'fr' => ['name' => 'French', 'script' => 'Latn', 'native' => 'franais', 'regional' => 'fr_FR'],
4 // ... other unused languages can remain commented ...
5],

For this example, nothing else needs to be customized in this file.

Configure Middleware

To enable the various localization features such as route translations, language detection and redirect, register the package's middleware:

app/Http/Kernel.php

1protected $routeMiddleware = [
2 // ... built-in Laravel middleware (auth, cache, etc.) ...
3 
4 // Add the following middleware from Laravel Localization:
5 'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class,
6 'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class,
7 'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class,
8 'localeCookieRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleCookieRedirect::class,
9 'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class
10];

Frontend: Setup the index page

We'll create a basic index page to list all the news articles. Let's start with a /news route:

routes/web.php

1use App\Models\Article;
2 
3Route::group([
4 'prefix' => LaravelLocalization::setLocale(),
5 'middleware' => ['localize', 'localeSessionRedirect', 'localizationRedirect', 'localeViewPath'],
6], function () {
7 Route::get('news', function () {
8 return view('site.articles.index', [
9 'articles' => Article::published()->orderBy('created_at', 'desc')->get(),
10 ]);
11 })->name('articles');
12});

Then, add a generic layout for all news pages:

1<!DOCTYPE html>
2<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
3 <head>
4 <meta charset="utf-8">
5 <title>The News</title>
6 </head>
7 <body>
8 <header>
9 <ul>
10 @foreach(LaravelLocalization::getSupportedLocales() as $localeCode => $properties)
11 <li>
12 <a
13 rel="alternate"
14 hreflang="{{ $localeCode }}"
15 href="{{ LaravelLocalization::getLocalizedURL($localeCode, null, [], true) }}"
16 >
17 {{ strtoupper($localeCode) }}
18 </a>
19 </li>
20 @endforeach
21 </ul>
22 </header>
23 <main>
24 @yield('content')
25 </main>
26 </body>
27</html>

Then, add a template for the articles index page:

1@extends('site.layouts.news')
2 
3@section('content')
4 <h1>{{ __('news.all_news') }}</h1>
5 
6 @if ($articles->isNotEmpty())
7 <ul>
8 @foreach ($articles as $article)
9 <li>
10 <a href="{{ route('article', $article->slug) }}">{{ $article->title }}</a>
11 </li>
12 @endforeach
13 </ul>
14 @else
15 <p>{{ __('news.no_news') }}</p>
16 @endif
17@endsection

With this, we have a functioning language switcher. Switching between languages should change the language code prefix in the URL. The list of article titles should also follow the selected language. Let's keep going!

Setup the single article page

Add the /news/article-slug route to the translated routes group defined earlier:

1use App\Models\Article;
2use Illuminate\Support\Facades\Route;
3use Illuminate\Support\Facades\Route;
4 
5Route::group([
6 'prefix' => LaravelLocalization::setLocale(),
7 'middleware' => ['localize', 'localeSessionRedirect', 'localizationRedirect', 'localeViewPath'],
8], function () {
9 Route::get('news', function () {
10 return view('site.articles.index', [
11 'articles' => Article::published()->orderBy('created_at', 'desc')->get(),
12 ]);
13 })->name('articles');
14 
15 Route::get('news/{article}', function (Article $article) {
16 return view('site.articles.show', [
17 'article' => $article,
18 ]);
19 })->name('article');
20});

Since we're trying to rely on Laravel's route-model binding, we must make a small addition to our Article model to find the appropriate article by slug:

app/Models/Article.php

1<?php
2 
3namespace App\Models;
4 
5use A17\Twill\Models\Behaviors\HasSlug;
6use A17\Twill\Models\Behaviors\HasTranslation;
7use A17\Twill\Models\Model;
8use App\Repositories\ArticleRepository;
9use Mcamara\LaravelLocalization\Interfaces\LocalizedUrlRoutable;
10 
11class Article extends Model implements LocalizedUrlRoutable
12{
13 use HasTranslation;
14 use HasSlug;
15 
16 protected $fillable = [
17 'published',
18 'title',
19 'description',
20 ];
21 
22 public $translatedAttributes = [
23 'title',
24 'description',
25 ];
26 
27 public $slugAttributes = [
28 'title',
29 ];
30 
31 public function resolveRouteBinding($slug, $field = null)
32 {
33 $article = app(ArticleRepository::class)->forSlug($slug);
34 
35 abort_if(! $article, 404);
36 
37 return $article;
38 }
39 
40 // #region routekey
41 public function getLocalizedRouteKey($locale)
42 {
43 return $this->getSlug($locale);
44 }
45 
46 // #endregion routekey
47}

Finishing touches

We're making good progress, but there are a few problems with our current solution:

  • The URL base is not translated (we get /news in both languages)
  • Some static text is not translated ("All Articles", "Back", etc.)
  • Our language switcher is not quite aware of the translated slugs (we get English slugs for the French link in the language switcher, and vice versa)

Translate the base URL

To translate route segments, Laravel Localization uses standard Laravel language files. We'll create two new files for our route translations in English and French:

resources/lang/en/routes.php

1<?php
2 
3return [
4 'articles' => 'news',
5 'article' => 'news/{article}',
6];

resources/lang/fr/routes.php

1<?php
2 
3return [
4 'articles' => 'actualites',
5 'article' => 'actualites/{article}',
6];

Then, we'll update our route definitions to make use of the transRoute helper instead of hardcoded values:

routes/web.php

1use App\Models\Article;
2use Illuminate\Support\Facades\Route;
3use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
4 
5Route::group([
6 'prefix' => LaravelLocalization::setLocale(),
7 'middleware' => ['localize', 'localeSessionRedirect', 'localizationRedirect', 'localeViewPath'],
8], function () {
9 Route::get(LaravelLocalization::transRoute('routes.articles'), function () {
10 return view('site.articles.index', [
11 'articles' => Article::published()->orderBy('created_at', 'desc')->get(),
12 ]);
13 })->name('articles');
14 
15 Route::get(LaravelLocalization::transRoute('routes.article'), function (Article $article) {
16 return view('site.articles.show', [
17 'article' => $article,
18 ]);
19 })->name('article');
20});

Translate static text

As alluded to before, the same method can be used to translate our static text. We'll create two additional files for our text translations in English and French:

resources/lang/en/news.php

1<?php
2 
3return [
4 'all_news' => 'All Articles',
5 'no_news' => 'Nothing here :(',
6 'back' => 'Back',
7];

resources/lang/fr/news.php

1<?php
2 
3return [
4 'all_news' => 'Tous les articles',
5 'no_news' => 'Rien ici :(',
6 'back' => 'Retour',
7];

Fix the language switcher

Because we're using route-model binding, all that's needed to fix our language switcher issue is to implement the LocalizedUrlRoutable interface from Laravel Localization in our model. For this, we'll add a new method:

app/Models/Article.php

1<?php
2 
3namespace App\Models;
4 
5use A17\Twill\Models\Behaviors\HasSlug;
6use A17\Twill\Models\Behaviors\HasTranslation;
7use A17\Twill\Models\Model;
8use App\Repositories\ArticleRepository;
9use Mcamara\LaravelLocalization\Interfaces\LocalizedUrlRoutable;
10 
11class Article extends Model implements LocalizedUrlRoutable
12{
13 use HasTranslation;
14 use HasSlug;
15 
16 protected $fillable = [
17 'published',
18 'title',
19 'description',
20 ];
21 
22 public $translatedAttributes = [
23 'title',
24 'description',
25 ];
26 
27 public $slugAttributes = [
28 'title',
29 ];
30 
31 public function resolveRouteBinding($slug, $field = null)
32 {
33 $article = app(ArticleRepository::class)->forSlug($slug);
34 
35 abort_if(! $article, 404);
36 
37 return $article;
38 }
39 
40 // #region routekey
41 public function getLocalizedRouteKey($locale)
42 {
43 return $this->getSlug($locale);
44 }
45 
46 // #endregion routekey
47}

And there we have it, a fully translated frontend! The articles index URLs are:

and the single article URLs will look like:

Where to go from here?

Explore configuration options

The config/laravellocalization.php file contains a few more options that can be customized. One of the most useful is probably hideDefaultLocaleInURL, which can be used to hide the language prefix from the URLs for your default language (/en, in this example).

Similarly, the config/translatable.php file published by Twill can be customized.

Twill's own UI has also been translated in multiple languages. This can be configured in config/twill.php:

1<?php
2 
3return [
4 'locale' => 'fr',
5 'fallback_locale' => 'en',
6 //...
7];

Handle inactive languages

In this example, all articles are shown on the index page, regardless of their active state ("Live" checkbox in the CMS) . Filtering articles on the "active" property is a simple way to hide inactive article translations:

routes/web.php

1Route::get(LaravelLocalization::transRoute('routes.articles'), function () {
2 return view('site.articles.index', [
3 'articles' => Article::published()->orderBy('created_at', 'desc')->get()
4 ->filter(function ($article) { return $article->active; }),
5 ]);
6})->name('articles');

From the HasTranslation model behavior, the hasActiveTranslation() method can be used to check if a given article has an active translation for a given locale. To check against the current locale:

1$isActive = $article->hasActiveTranslation(app()->getLocale());

This way, you can decide what to do when a user tries to access an inactive translation (e.g. redirect to another language, redirect to the index page, trigger a 404, etc.)

Redirect old slugs

Out of the box, every time a slug is changed for a given article in the CMS, Twill keeps a record of the old slug in the article_slugs table. We can leverage this to conveniently redirect our users (and search engine crawlers ;) to the most up to date URL:

routes/web.php

1Route::get(LaravelLocalization::transRoute('routes.article'), function (Article $article) {
2 if ($article->redirect) {
3 if ($article->hasActiveTranslation(app()->getLocale())) {
4 return redirect(route('article', $article->slug));
5 }
6 abort(404);
7 }
8 
9 return view('site.articles.show', [
10 'article' => $article,
11 ]);
12})->name('article');