We recently localized Siggy, our Laravel-based web app. It was frustrating to not find one comprehensive guide on how to localize an app end-to-end.
I wrote this to cover as much as possible on how to localize your Laravel application using the URL as the context (e.g. example.com/en/, example.com/fr/) It is written and tested with Laravel 8.
You can checkout the codebase for this guide to follow along.
What's covered
Feel free to jump to a that applies to you if you do not want to follow the whole guide.
- Introduction to localization
- Localization approaches
- Setting up the new Laravel application
- Locale configuration/scaffolding
- Basic translation
- Building the language switcher
- Translating static pages
- Localize menu links, authentication routes
- (Bonus) Form validation
- Learning References
- Resources
1. Introduction to localization
๐บ๐ธ ๐ท๐บ ๐ฎ๐ณ ๐ฉ๐ช ๐ฆ๐บ ๐ฉ๐ฐ ๐ง๐ท ๐จ๐ณ
Locale != Country != Language
It is important in some locales (within a country), different languages are used. For example, pt-BR (Portuguese used in Brazil) and pt-PT (Portuguese used in Portugal).
Sometimes it is ok to think of locales just as languages (English, Portuguese, Chinese, etc) if you simply want to provide language translations. However, understanding the nuances in a locale can be helpful if your goal is to serve a specific market.
2. Localization approaches
There are many approaches in building localization into an application, usually it consists of three parts:
Detecting the locale
The "locale context" can be detected in many different ways via Auto locale detection and/or Manual context
Auto locale detection
For example, you can use language detection from the user's web browser preferences to guess the locale. You can also use the geolocation information (e.g. IP address) to identify the location.
(For example, setting your google account language preference will provide a language context to google Chrome.)
(Subsequently, Google will use this context to localize its content for you.)
Manual context
Manual context means the user explicitly gives you the information on their language/locale preferences. The most common example is the user using a language switcher on a website. Additionally, if the user revisits an app/website frequently, they can also set a language preference under their user account.
There is no right or wrong way to obtain the context from the user, it is often the best practice to use a combination of auto locale detection and manual context (override).
Setting the locale
Once you have determined the user's locale, you can set the locale in the app so it knows how to personalize/translate the user interface and contents. This can be done via the URL (e.g. example.com/en/, example.com/fr/), via a session variable, a cookie, or setting the locale persistently for the user in the app database.
For this guide, we will be using the URL to set the locale for the user.
Translation
Finally, there will be translated content, UI elements, and others associated with each locale. Translation can be provided by either:
- Machine translation from google translate or similar via an API.
- Manual translation from humans provided in translation strings
We will be using manual translations in this guide.
3. Setting up the new Laravel application
In this example, we are setting up a vanilla Laravel (not Jetstream or other enhanced starter versions).
Create a new Laravel project, in your command line
Laravel new localization-example
(Assuming you have Laravel installed globally)
Under your project .env file, make the necessary configuration changes, at a minimal set up a database connection for the app.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=example
DB_USERNAME=user
DB_PASSWORD=pass
Add a simple authentication package for the project, in your command line
composer require laravel/ui --dev
Install the authentication scaffolding
php artisan ui vue --auth
If needed, re-compile the assets (CSS/javascript)
npm run dev
Finally, run the database migration command in your command line to create the authentication-related database tables:
php artisan migrate
Fire up the local server
php artisan serve
You should now have a bare bone Laravel app with authentication that looks like (http://127.0.0.1:8000/):
4. Locale configuration/Scaffolding
Locale configuration
We will first create our locale configuration file under the config
directory of your Laravel app called locale.php
, we will rely on this set of configurations for our locale checking/setting. We define the list of locales allowed for our app as well as a default locale.
##
# Custom configuration for our app locales
#
return [
'locales' => ['da', 'en', 'zh_CN'],
'default_locale' => 'en',
];
There is a reason we are adding a
default_locale
configuration vs. using app.locale which will be explained later.
Creating a middleware
Next, we will create a middleware that sets the locale based on the URL pattern (e.g. example.com/da/). The user will manually provide their locale to us via a language switcher widget on the website.
In your command line
php artisan make:middleware SetLocale
Inside of the middleware we have created, it should look like:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;
class SetLocale
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$locale = $request->segment(1);
if (in_array($locale, config('locale.locales'))) {
App::setLocale($locale);
}
return $next($request);
}
}
This essentially checks for the first part of the URL path (e.g. example.com/en/some/path) and sets the app locale if it matches with one of the locales (en) in our configuration. It prevents users from attempting to guess for the locale in the URL that does not exist (e.g. example.com/foo)
Also, add the new middleware in the app/http/Kernel.php
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\App\Http\Middleware\SetLocale::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
We will come back to the middleware later to add default URL parameters.
Routing groups and default locale
We will now create a new route group in the web.php
route file. This will add the locale prefix for all of the routes in this group.
For example, let's move the default home page route under the routing group
Route::group(['prefix' => '{locale}'], function() {
Route::get('/', function () {
return view('welcome');
})->name('welcome');
});
As you can see, the new home can be accessed via different locales e.g. example.com/en/, example.com/fr/, etc. However, if you access the root URL, you will get a 404 because it no longer exists.
Let's add a redirect from the root URL in the web.php
. This will redirect to a default locale (in our case /en)
Route::get('/', function () {
return redirect()->route('welcome', ['locale' => config('locale.default_locale')]);
});
// Localized routes
Route::group(['prefix' => '{locale}'], function() {
Route::get('/', function () {
return view('welcome');
})->name('welcome');
});
5. Basic translation
To recap, at this point we have:
- A new Laravel app scaffolded with authentication
- We have created a custom configuration
locale.php
to house the list of allowed locales and the default locale for our app (In this case, en, da, zh_CN) - We have created a middleware that takes the first part of the URL path and use it to detect and set the locale (e.g. example.com/en/, example.com/zh_CN/)
- We created a new route group in
web.php
and moved our home page route there.
At this point, all we need is some translated content and we can show some localization! Let's work on the home page (resources/views/welcome.blade.php
)
Making texts translatable
The first thing we need to do is make sure the texts (strings) in the template are translatable. This means they should look like the following:
{{ __('Some translatable string') }}
For demo purposes, let's find the following texts from the welcome.blade.php
and wrap them in the {{ __() }}
function :
For example, the top navigation links now should look similar to:
@if (Route::has('login'))
<div class="hidden fixed top-0 right-0 px-6 py-4 sm:block">
@auth
<a href="{{ url('/home') }}" class="text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Home') }}</a>
@else
<a href="{{ route('login') }}" class="text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Log in') }}</a>
@if (Route::has('register'))
<a href="{{ route('register') }}" class="ml-4 text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Register') }}</a>
@endif
@endauth
</div>
@endif
Creating the translation file
Now the texts are translatable, we are finally able to provide the translation for them. There are several ways to do this but we will be using the {locale}.json method.
We will provide a Chinese simplified translation file in resources/lang/zh_CN.json
{
"Documentation": "ๆๆกฃ",
"Laravel News": "Laravel ๆฐ้ป",
"Vibrant Ecosystem": "ๅ
ๆปกๆดปๅ็็ๆ็ณป็ป",
"Home": "ไธป้กต",
"Login": "็ป้",
"Log in": "็ป้",
"Register": "ๆณจๅ"
}
Now if you visit the homepage (http://127.0.0.1:8000/zh_CN/
) you should see these texts translated!
We only translated part of the page as a demo, we will come back to this later to show a way to translate the content of the entire page (static page)
6. Building the language switcher
We have now built the essential framework for localizing content in the app, the only main component left is the language switcher.
There are 3 parts to the language switcher: the template markup, CSS, and javascript.
The template markup
Let's create a blade template called resources/views/switcher.blade.php
<select class="lang-switcher">
<option value="en" class="test" data-flag="us">English</option>
<option value="zh_CN" data-flag="cn">็ฎไฝไธญๆ</option>
<option value="da" data-flag="dk">Dansk</option>
</select>
<div class="lang-select">
<button class="lang-button" value=""></button>
<div class="lang-b">
<ul id="lang-a"></ul>
</div>
</div>
The CSS
Copy the CSS from the codepen into resources/css/app.css
, copy the corresponding flag icon svg into the public/img/flags/
directory, the CSS for your flag icons should look like:
.flag-icon-cn {
background-image: url('/img/flags/cn.svg');
}
.flag-icon-dk {
background-image: url('/img/flags/dk.svg');
}
.flag-icon-us {
background-image: url('/img/flags/us.svg');
}
The Javascript
The script should be placed into resources/js/switcher.js
, note that it requires jQuery to run.
Putting it together
First, we will also need to adjust the webpack.mix.js
file for the app to compile our new javascript and CSS. This will add our javascript and CSS into the compiled public/css/app.css
and create a new public/js/switcher.js
respectively.
mix.js('resources/js/app.js', 'public/js')
.js('resources/js/switcher.js', 'public/js/switcher.js')
.vue()
.sass('resources/sass/app.scss', 'public/css')
.css('resources/css/app.css', 'public/css');
Then simply run the build in the command line
npm run dev
Now, let's add jQuery, the switcher.js
and app.css
into the <head>
section of the resources/views/welcome.blade.php
since it uses its own HTML template. ๐คฆ
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="{{ asset('js/switcher.js') }}"></script>
<link href="{{ asset('css/app.css') }}" rel="stylesheet" />
We then also drop the language switcher @include('switcher')
in before the login link in resources/views/welcome.blade.php
@if (Route::has('login'))
<div class="hidden fixed top-0 right-0 px-6 py-4 sm:block">
@include('switcher')
@auth
<a href="{{ url('/home') }}" class="text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Home') }}</a>
@else
<a href="{{ route('login') }}" class="text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Log in') }}</a>
@if (Route::has('register'))
<a href="{{ route('register') }}" class="ml-4 text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Register') }}</a>
@endif
@endauth
</div>
@endif
Finally, we need to initialize the language switcher, let's add some javascript before the end of <body>
tag.
<script type="text/javascript">
$(function() {
let switcher = new Switcher();
switcher.init();
});
</script>
Now if you visit the app at http://127.0.0.1:8000/en/, you should see the language switcher at work!
7. Translating static pages
The example above is great for translating text (strings) on a page. However, if we have an entire page of content, the {locale}.json method is not practical.
Let's create a controller (in the command line):
php artisan make:controller PageController
The controller should look like:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PageController extends Controller
{
/**
* Static page router
*/
public function page($locale, $page) {
// Views directory are structured with locales as sub directories
$default_dir = 'pages.' . config('locale.default_locale'); // default locale
if (in_array($locale, config('locale.locales'))) {
$view_dir = 'pages.' . $locale;
}
// Return the default language page when there's no corresponding view found
try {
return view($view_dir . '.' . $page);
} catch(\InvalidArgumentException $e) {
return view($default_dir . '.' . $page);
}
}
}
The controller simply looks for blade templates in the views directory such as /resources/views/pages/{locale}/{page_name}.blade.php
and serve it.
Next, let's create some example contents, we will create 2 template files, /resources/views/pages/en/example.blade.php
and /resources/views/pages/zh_CN/example.blade.php
<!-- /resources/views/pages/en/example.blade.php -->
@extends('layouts.app', ['class' => 'example'])
@section('content')
<p>Winnie-the-Pooh, also called Pooh Bear and Pooh, is a fictional anthropomorphic teddy bear created by English author A. A. Milne and English illustrator E. H. Shepard.</p>
<p>The first collection of stories about the character was the book Winnie-the-Pooh (1926), and this was followed by The House at Pooh Corner (1928). Milne also included a poem about the bear in the children's verse book When We Were Very Young (1924) and many more in Now We Are Six (1927). All four volumes were illustrated by E. H. Shepard.</p>
<p>The Pooh stories have been translated into many languages, including Alexander Lenard's Latin translation, Winnie ille Pu, which was first published in 1958, and, in 1960, became the only Latin book ever to have been featured on The New York Times Best Seller list.</p>
<p>In 1961, Walt Disney Productions licensed certain film and other rights of Milne's Winnie-the-Pooh stories from the estate of A. A. Milne and the licensing agent Stephen Slesinger, Inc., and adapted the Pooh stories, using the unhyphenated name "Winnie the Pooh", into a series of features that would eventually become one of its most successful franchises.</p>
<p>In popular film adaptations, Pooh has been voiced by actors Sterling Holloway, Hal Smith, and Jim Cummings in English, and Yevgeny Leonov in Russian.</p>
@endsection
<!-- /resources/views/pages/zh_CN/example.blade.php -->
@extends('layouts.app', ['class' => 'example'])
@section('content')
<p>ๅฐ็็ปดๅฐผ๏ผไธญๅฝๅคง้ไนไฝๅๅ็๏ผ[1]๏ผ1925ๅนด12ๆ24ๆฅ้ฆๆฌก้ขไธ๏ผไปฅๅฃ่ฏๆ
ไบๅฝขๅผๅจไผฆๆฆใๆฐ้ปๆๆฅใๅๅบ๏ผ็ฌฌไธๆฌใๅฐ็็ปดๅฐผใๆ
ไบไนฆไบ1926ๅนด10ๆๅบ็ใๅฐ็็ปดๅฐผๆฏ่พไผฆยทไบๅๅฑฑๅคงยท็ฑณๆฉไธบไป็ๅฟๅญๅไฝ็ไธๅช็้ ๅๅก้ๅฝข่ฑก๏ผ่ๅคๅ
ธ็ปดๅฐผๆฏ็ฑ่ฐขๅนๅพท๏ผE.H.Shepard๏ผๆ็ป๏ผๅ็ฑๅ็นยท่ฟชๅฃซๅฐผๅ
ฌๅธ่ดญๅ
ฅๅนถ็ป่ฟ้ๆฐ็ปๅถ๏ผๆจๅบๅๅ ๅ
ถๅฏ็ฑ็ๅคๅไธๆจๅ็ไธชๆง๏ผ่ฟ
้ๆไธบไธ็็ฅๅ็ๅก้่ง่ฒไนไธใๆญคๅไบบไปฌไธบไบๅบๅซไธค็งไธๅ้ฃๆ ผ็็ปดๅฐผ๏ผ็งฐๅผ็ฑณๅฐๆฉๆถๆ็ฑ่ฐขๅนๅพท๏ผE.H.Shepard๏ผ็ปๅถ็็ปดๅฐผไธบโๅคๅ
ธ็ปดๅฐผโ๏ผClassic Pooh๏ผ๏ผ่ๅๅ
จ็็ฒไธไพฟๆ็ฑณๅฐๆฉ็็ๆฅ๏ผ1ๆ18ๆฅ๏ผๆจไธบๅฝ้
ๅฐ็็ปดๅฐผๆฅใ</p>
@endsection
Next, let's add the controller method in the routing file web.php
, our route group should now look like:
// Localized routes
Route::group(['prefix' => '{locale}'], function() {
Route::get('/', function () {
return view('welcome');
})->name('welcome');
Route::get('/example', [App\Http\Controllers\PageController::class, 'page'])
->name('example')
->defaults('page', 'example');
});
We are using default parameters to tell the controller which corresponding page view to use.
Finally, similar to what we did in the welcome.blade.php
, let's add the language switcher in the /resources/views/layouts/app.blade.php
In the <head>
section
<script src="{{ asset('js/switcher.js') }}" defer></script>
Next, in the top right navigation menu, add the switcher markup:
<!-- Right Side Of Navbar -->
<ul class="navbar-nav ml-auto">
<li class="nav-item pt-2">@include('switcher')</li>
Finally, before the end of <body>
<script type="text/javascript">
$(function() {
let switcher = new Switcher();
switcher.init();
});
</script>
Now let's hit the URL http://localhost:8000/en/example, you should see the page with the language switcher in action:
8. Localize menu links, authentication routes
After translating basic text strings and static content, let's look at the authentication pages. These are the login, registration, password reset pages provided by Laravel when we first created the project under 3. Setting up the new Laravel application
Localize menu routes
The first step is to add the authentication routes into our route group in your web.php
:
// Localized routes
Route::group(['prefix' => '{locale}'], function() {
Route::get('/', function () {
return view('welcome');
})->name('welcome');
Route::get('/example', [App\Http\Controllers\PageController::class, 'page'])
->name('example')
->defaults('page', 'example');
Auth::routes();
});
After doing this, when you visit the http://localhost:8000, you might see an error page that looks like:
This error occurs because we have links in templates that are not localized. In this case in our /resources/views/welcome.blade.php
we have links that look like:
<a href="{{ route('login') }}" class="text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Log in') }}</a>
We will need to go through the codebase for all blade templates under /resources/views
and replace all the links with localized versions.
For example:
<a href="{{ route('login', ['locale' => app()->getLocale()]) }}" class="text-sm text-gray-700 dark:text-gray-500 underline">{{ __('Log in') }}</a>
Once all the links are localized, we can now access the localized authentication pages again (user login, registration, etc)
Translate authentication routes
To translate the texts on the authentication routes, we will take a lazy approach of using contributed language starter packs from https://github.com/Laravel-Lang/lang since these are standard texts for most of the web applications.
Let's download the repo and take the locale we want (zh_CN
):
Notice it comes with a zh_CN.json
as well as other authentication, validation related translation files.
Let's copy the entire
zh_CN
directory to your Laravel project under/resources/lang
.Combine the content of the
zh_CN.json
into the one we have created previously, be aware there might be some duplicated JSON keys such aslogin
,register
.
If you come back to the authentication pages, most of the content is now translated!
As a bonus, the validation messages are also translated:
Login/Registration redirect
Now that we can access the authentication routes such as the login/registration form, let's make the form redirects to their localized destinations.
You should be able to find LoginController.php
and RegisterController.php
under app/Http/Controllers/Auth
in the project directory.
Inside of the controller, simply create a method called redirectTo
. In our example, I am redirecting the user to the example page (e.g. /en/example) after they log in.
public function redirectTo()
{
return app()->getLocale() . '/example';
}
Password Reset Email
If we try to reset our password via the password reset form, we will get an URL generation error.
Since these authentication controllers come from a package and we cannot change their URL parameters without changing the package code, the easiest solution is the set a default URL parameter for them.
In your SetLocale.php
middleware, let's bring in the URL facade and set a default parameter of locale
.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;
class SetLocale
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$locale = $request->segment(1);
if (in_array($locale, config('locale.locales'))) {
App::setLocale($locale);
URL::defaults(['locale' => $locale]);
}
return $next($request);
}
}
It is worth mentioning from the official Laravel documentation to set the appropriate
$middlewarePriority
in theapp/Http/Kernel.php
with theSetLocale.php
middleware to not interfere with Laravel's route model binding.
9. (Bonus) Form validation messages
Custom Forms are a common part of any web apps today and form validation messages are easily overlooked in localization.
If you have downloaded the corresponding language files from Translate authentication routes section earlier, this is a much easier task (Some default validation messages are already translated!!).
Let's first create a view under resources/views/form.blade.php
. This will be a very simple example form that asks the user to enter an integer number to demonstrate the form validation messages.
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">{{ __('Example Form') }}</div>
<div class="card-body">
<form method="POST" action="{{ route('example-form', ['locale' => app()->getLocale()]) }}">
@csrf
<div class="form-group row">
<label for="int_number" class="col-md-4 col-form-label text-md-right">{{ __('Enter a integer number') }}</label>
<div class="col-md-6">
<input id="int_number" type="text" class="form-control @error('int_number') is-invalid @enderror" name="int_number" value="{{ old('int_number') }}" autofocus>
@error('int_number')
<span class="invalid-feedback" role="alert">
<strong>{{ $message }}</strong>
</span>
@enderror
</div>
</div>
<div class="form-group row mb-0">
<div class="col-md-8 offset-md-4">
<button type="submit" class="btn btn-primary">
{{ __('Submit') }}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
Next, let's create a controller for the form, in your command line:
php artisan make:controller ExampleFormController
Inside of that controller, we will create two simple methods, one to render the form, one to check the form input.
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ExampleFormController extends Controller
{
function view() {
return view('form');
}
function store() {
$validation_rules = [
'int_number' => 'required|integer',
];
request()->validate($validation_rules);
return redirect()->route('example-form', ['locale' => app()->getLocale()]);
}
}
We specified 2 rules for our form input, making it required and making sure the user enters an integer.
Let's add routing to the controller actions, in the routes/web.php
, let's add 2 more routes inside of our routing group Route::group(['prefix' => '{locale}'], function() { ... })
Route::get('/form', [App\Http\Controllers\ExampleFormController::class, 'view'])->name('example-form');
Route::post('/form', [App\Http\Controllers\ExampleFormController::class, 'store']);
Finally, we can translate the form title, input, etc by adding lines to the zh_CN.json
as we learned from earlier:
{
...
"Example Form": "็คบไพ่กจๆ ผ",
"Submit": "ๆไบค",
"Enter a integer number": "่พๅ
ฅไธไธชๆดๆฐ"
}
Now if you go to http://localhost:8000/zh_CN/form
, you should see the localized form:
The problem is that if we try to trigger a validation message, you will see part of the validation message translated.
The translated validation message comes from the language files we downloaded earlier. Specifically, you can find a file called /resources/lang/zh_CN/validation.php
.
In this file, let's add an element called attributes
towards the end of the file:
return [
...
'attributes' => [
'int_number' => 'ๆดๆฐ',
],
];
The element name int_number
is the name
attribute from the input we specified in the blade template earlier. Now when the validation message is triggered, everything should be translated.
Learning References
Multi-Language Routes and Locales with Auth
Top comments (0)