How To Build An Efficient and SEO Friendly Multilingual Architecture in Laravel - UPDATED GUIDE
This is a remastered version of my previous tutorials How To Build An Efficient and SEO Friendly Multilingual Architecture For Your Laravel Application and Laravel 5 And His F*cking non-persistent App SetLocale.
This update was needed so we could use the latest packages and the latest version of Laravel (5.7). This is a cleaner and updated version of the solution, which will solve a few issues from the previous tutorial.
Also, we will cover the Laravel Nova implementation as well π
As previously, a Github repository is available as a demo for you to try the solution yourself and skip the reading π
If you're new here, let me give you a quick intro of what we're doing.
I encourage you to read every word of this tutorial as there is a lot of information to understand.
The goal is to use the Laravel to build an application that supports a fully featured multilingual architecture, where everything can be translated, from your database to your static content and routes.
But of course, we need to take SEO into consideration! Meaning we want the language code in the URL! And translated URI's!
This is the sad part of the story: MariaDB is definitely not supported, at least not yet.
Some of the packages we are going to use make a extensive use of JSON type columns and JSON query operators.
Even if MariaDB "officially" supports JSON columns, they actually only transform your column into a TEXT type column. Plus, they don't support the ->
json where operator at all.
MariaDB is thus not supported at all. You will absolutely need MySQL 5.7 or later.
Personally, that's the reason why I completely switched my whole server database driver from MariaDB to MySQL. It was really not that difficult, and I'm glad I made the change π
Those are the MUST have features that we want to cover in order to make this solution viable.
I assume that you already have your base application up and running, or at least some Models, Migrations and Controllers.
But for this tutorial, I'll quickly set up a Base Laravel Application for a basic Blog, with Users and Posts. Won't take much time πββοΈ
Generate a Post model with its migration and controller:
php artisan make:model Post -m -c
We will set the content of the generated files in the next steps.
We need one package to generate and handle unique slugs, and another package to store and handle the translations of a single model.
composer require spatie/laravel-translatable
php artisan vendor:publish --provider="Spatie\Translatable\TranslatableServiceProvider"
In the generated file, we need to set the fallback to null
// config/translatable.php
return [
/*
* If a translation has not been set for a given locale, use this locale instead.
*/
'fallback_locale' => null,
];
This trick will allow you to re-generate the slug of a model when you translate it. It will allow you to do this:
$post = new Post;
app()->setLocale('en');
$post->title = 'Awesome Translated Post!';
$post->content = 'Hello World! It works!!';
$post->save();
app()->setLocale('fr');
$post->title = 'Super Article traduit en français!';
$post->content = 'Bonjour le monde! Ca fonctionne bien !!';
$post->save();
// slugs will be correctly translated
For some obscure reason, the laravel-sluggable package from Spatie is not compatible in our situation. I tried it in ten different ways, but the model's slug was never correctly updated in the correct locale.
I ended up using this very good sluggable package:
composer require cviebrock/eloquent-sluggable
php artisan vendor:publish --provider="Cviebrock\EloquentSluggable\ServiceProvider"
// App\Post.php
use Spatie\Sluggable\SlugOptions;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
use Cviebrock\EloquentSluggable\Sluggable;
class Post extends Model
{
use Sluggable, HasTranslations;
public $translatable = ['title', 'slug', 'content'];
public function sluggable()
{
return [
'slug' => [
'source' => 'title'
]
];
}
public function getRouteKeyName()
{
return 'slug';
}
}
The $translatable
public property is where you list all attributes from the model that will be translatable. Those are the same attributes that use the JSON type column in the migrations:
Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->json('title'); // Every translatable attributes need to be have a JSON type column
$table->json('slug');
$table->json('content');
$table->timestamps();
});
You now understand that all translations will be stored into these JSON columns.
Ok! We have a working logic to have translated content. But we still need to set up some logic to display your application views in the correct language, build a language switcher and manage the routes.
As you see in our Post model, I've overridden the getRouteKeyName
method. This won't work out of the box unfortunately, we need to implement a custom logic. So in the RouteServiceProvider you need to register a custom binding.
// App/Providers/RouteServiceProvider.php
public function boot()
{
parent::boot();
$locale = request()->segment(1);
Route::bind('post', function ($slug) use ($locale) {
return \App\Post::where('slug->' . $locale, $slug)->first() ?? abort(404);
});
}
In order to begin testing this live, I've made a very quick Seeder class and some layouts, that you can find here:
PostsSeeder : https://github.com/mydnic/Laravel-SEO-Multilingual-Architecture-V2/blob/master/database/seeds/PostsTableSeeder.php
PostController : https://github.com/mydnic/Laravel-SEO-Multilingual-Architecture-V2/blob/master/app/Http/Controllers/PostController.php
Posts List view : https://github.com/mydnic/Laravel-SEO-Multilingual-Architecture-V2/blob/master/resources/views/post/index.blade.php
Note that I generate the auth views with php artisan make:auth
Notice how our Controller and our Blade view is no different than your usual Laravel App? Yep! That's the point of this implementation: you get to continue developing your application like before. You don't even need to think about the translatable architecture when developing.
Here's what we got at the moment:
We are not going to create a new config file to store our available locales. Let's simply use config/app.php
and add this right after the 'locale'
key:
'locales' => [
'en' => 'English',
'fr' => 'Français',
// Add as many languages you want
],
It may look a bit weird to have this array right below the locale
key, but unfortunately, we can't delete it, because it is used by Spatie's package. It's ok though, don't worry about that.
If you want, you may, of course, create your own config/locales.php
file. It's up to you.
Anyway, that's the easiest way to cope with this point.
You can still use 'fallback_locale' => 'en',
to set the fallback locale.
That's the first step to do.
We want to force every route to have the language code in the first segment of the URL.
So your homepage will look like this:
The correct way to handle this is to add a new middleware. Let's use the good ol' Language Middleware:
php artisan make:middleware Language
// App/Http/Middleware/Language.php
public function handle($request, Closure $next)
{
$locales = config('app.locales');
// Check if the first segment matches a language code
if (!array_key_exists($request->segment(1), config('app.locales'))) {
// Store segments in array
$segments = $request->segments();
// Set the default language code as the first segment
$segments = \Arr::prepend($segments, config('app.fallback_locale'));
// Redirect to the correct url
return redirect()->to(implode('/', $segments));
}
// The request already contains the language code
return $next($request);
}
However, we'll make an interesting change in comparison to the old tutorial. Instead of setting this middleware in the web
middleware group, we are going to separate it from any group.
The reason is that Laravel Nova, Laravel Telescope and Horizon all use the web middleware, but don't need the locale in the URL, obviously. We will only use this middleware for our application, and we kinda want to control that.
Thus, we define our middleware in the $routeMiddleware
protected property of the Http/Kernel.php
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'localized' => \App\Http\Middleware\Language::class, // Here it is
];
That allows you to apply the "enforcing" to specific routes only.
Now you actually need to apply this middleware to every route in your application.
You can either handle this inside your routes/web.php
file if you want to control every part of your application.
Or you can use the RouteServiceProvider which is way cleaner.
protected function mapWebRoutes()
{
$locale = request()->segment(1);
Route::middleware(['web', 'localized'])
->prefix($locale)
->namespace($this->namespace)
->group(base_path('routes/web.php'));
}
By updating the middleware()
method, we apply the localized
middleware that we previously defined.
And we also use the prefix()
method in order to correctly map all URI. If you don't use the prefix, you'll get 404 everywhere, simply because the routes won't understand the locale code prefix.
You may now refresh your browser and see that the URL is automatically updated with the language prefix! Yay π
This was the hard part of the first tutorial, but now it's super easy!
All we need to do is grab the locale from the URL and tell laravel to set the locale accordingly.
We can do that with a single line of code in the AppServiceProvider
public function boot()
{
// Set the app locale according to the URL
app()->setLocale(request()->segment(1));
}
Now refresh your browser and.. surprised! Your post is translated!
At this point on your app, you can access
But what about the /post/
part? Can we translate that ?? Of course, you can! π€
You will need a file to store all translated routes segments
// resources/lang/en/routes.php
return [
'post' => 'post'
];
// resources/lang/fr/routes.php
return [
'post' => 'article'
];
Now in your routes/web.php file you can do this:
Route::get(trans('routes.post') . '/{post}', 'PostController@show')->name('post.show');
I know it looks a bit dirty, but it works amazingly well!
And the best part is that if you use the route()
helper in your views, it's updated automatically, as you can see & test in the Github Repo
Same thing for static pages like your about page
Route::view(trans('routes.about'), 'about')->name('page.about');
Two things to note about this solution:
In the demo, I'm using a standard dropdown component based on Bootstrap. Of course, you're free to use whatever you want.
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" id="languagesDropdown" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true">
{{ config('app.locales')[app()->getLocale()] }} <span class="caret"></span>
</a>
<div class="dropdown-menu " aria-labelledby="languagesDropdown">
@foreach (config('app.locales') as $localeKey => $locale)
@if ($localeKey != app()->getLocale())
<a class="dropdown-item" href="{{ route('locale.switch', $localeKey) }}">
{{ $locale }}
</a>
@endif
@endforeach
</div>
</li>
Notice that we're going to use a new route locale.switch
in order to handle the redirect. We need to this in order to be able to update the model's slug from the URL on the fly.
Register that new route:
Route::name('locale.switch')->get('switch/{locale}', 'LocaleController@switch');
Let's create the LocaleController
php artisan make:controller LocaleController
And here's where much of our code will be as this is the most tricky part of the tutorial.
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Lang;
class LocaleController extends Controller
{
protected $previousRequest;
protected $locale;
public function switch($locale)
{
$this->previousRequest = $this->getPreviousRequest();
$this->locale = $locale;
// Store the segments of the last request as an array
$segments = $this->previousRequest->segments();
// Check if the first segment matches a language code
if (array_key_exists($this->locale, config('app.locales'))) {
// Replace the first segment by the new language code
$segments[0] = $this->locale;
$newRoute = $this->translateRouteSegments($segments);
// Redirect to the required URL
return redirect()->to($this->buildNewRoute($newRoute));
}
return back();
}
protected function getPreviousRequest()
{
// We Transform the URL on which the user was into a Request instance
return request()->create(url()->previous());
}
protected function translateRouteSegments($segments)
{
$translatedSegments = collect();
foreach ($segments as $segment) {
if ($key = array_search($segment, Lang::get('routes'))) {
// The segment exists in the translations, so we will grab the translated version.
$translatedSegments->push(trans('routes.' . $key, [], $this->locale));
} else {
// Otherwise we simply reuse the same.
$translatedSegments->push($segment);
}
}
return $translatedSegments;
}
protected function buildNewRoute($newRoute)
{
$redirectUrl = implode('/', $newRoute->toArray());
// Get Query Parameters if any, so they are preserved
$queryBag = $this->previousRequest->query();
$redirectUrl .= count($queryBag) ? '?' . http_build_query($queryBag) : '';
return $redirectUrl;
}
}
I've done some refactoring but I didn't go too far in order to keep places for code comments, so you can easily understand what's going on π
In a few words, when hitting the switch method of the controller (basically when you use the language switcher), we will:
At this point, you should get this behavior:
Of course, if you didn't want to translate the /post/
segment, well, this segment will remain the same.
Unfortunately, when you reach example.com/fr/article/my-awesome-post
you'll get a nice 404 page. Why? Well simply because the slug doesn't exist in this locale. We still use the same slug as before, but the Laravel Locale is different. Thus, when using the Route Model Binding that we previously set up, you get a 404 because the Post model wasn't found.
We need to add an extra step to update the slug in the language switching process. The goal will be to have this:
example.com/en/post/my-awesome-post
β switch locale β example.com/fr/article/my-awesome-post
β example.com/fr/article/mon-incroyable-article
What's the solution here? When we're still in the LocaleController, we don't know anything about the model that we want to update, so we won't be able to query the new slug.
An easy option would be to "simply" add a condition in our controller to check if the slug exists in this model locale. But if we use Route Model Binding (which we are), then we can't fix that in the controller, as you'll get a 404 right when Laravel tries to resolve your model binding.
So why not add this condition in the model binding directly? Well... yeah.. why not?! Let's do that!
Remember our custom Route Model Binding defined in the RouteServiceProvider?
It looked like this:
$locale = request()->segment(1);
Route::bind('post', function ($slug) use ($locale) {
return \App\Post::where('slug->' . $locale, $slug)->first() ?? abort(404);
});
You clearly see that the 404 page is triggered here. Let's fix that.
New code:
$locale = request()->segment(1);
Route::bind('post', function ($slug) use ($locale) {
$post = Post::where('slug->' . $locale, $slug)->first();
if ($post) {
return $post;
} else {
foreach (config('app.locales') as $locale => $label) {
$postInLocale = Post::where('slug->' . $locale, $slug)->first();
if ($postInLocale) {
return redirect()->to(
str_replace($slug, $postInLocale->slug, request()->fullUrl())
)->send();
}
}
abort(404);
}
});
Ugh... That's a large piece of code. But it works. So what happens here?
We first try to grab the model with the given slug. If we find one, we return it and we are done. Otherwise, we will try to grab the model in every existing locale with the given slug. When we find it, we force-redirect to the correct URL, which is simply a string replacement of the wrong slug by the correct slug.
If you have a lot of Models to bind, we can refactor a bit:
public function boot()
{
parent::boot();
$locale = request()->segment(1);
Route::bind('post', function ($slug) use ($locale) {
return $this->resolveModel(Post::class, $slug, $locale);
});
Route::bind('video', function ($slug) use ($locale) {
return $this->resolveModel(Video::class, $slug, $locale);
});
}
protected function resolveModel($modelClass, $slug, $locale)
{
$model = $modelClass::where('slug->' . $locale, $slug)->first();
if (is_null($model)) {
foreach (config('app.locales') as $localeKey => $label) {
$modelInLocale = $modelClass::where('slug->' . $localeKey, $slug)->first();
if ($modelInLocale) {
return redirect()->to(
str_replace($slug, $modelInLocale->slug, request()->fullUrl())
)->send();
}
}
abort(404);
}
return $model;
}
This will be quick because Spatie's package already covers this! I recommend you to install their nova package:
https://github.com/spatie/nova-translatable
AND THAT'S IT GUYS!! You made it until the end!! Congratulations!
If you liked this video, be sure to like, subscribe and hit the notification bell! Wait... no... wrong media...
Hem, just share it then, with your friends, I guess. And smash the Like button a thousand times! β€οΈ
I consider myself as an IT Business Artisan. Or Consultant CTO. I'm a self-taught Web Developper, coach and teacher. My main work is helping and guiding digital startups.
more about meBTC
18SY81ejLGFuJ9KMWQu5zPrDGuR5rDiauM
ETH
0x519e0eaa9bc83018bb306880548b79fc0794cd08
XMR
895bSneY4eoZjsr2hN2CAALkUrMExHEV5Pbg8TJb6ejnMLN7js1gLAXQySqbSbfzjWHQpQhQpvFtojbkdZQZmM9qCFz7BXU
2024 © My Dynamic Production SRL All rights Reserved.