How To Build An Efficient and SEO Friendly Multilingual Architecture For Your Laravel Application

How To Build An Efficient and SEO Friendly Multilingual Architecture For Your Laravel Application

This article was updated to match Laravel 5.4

One day I wrote an article on this blog about solving an issue with the non-persitent App SetLocale of Laravel 5. To my big surprise this article became quite popular and received more or less 1.000 unique visits per month (for me, that's a lot).

There are many comments on this blog post and some of them give better approaches in making a multilingual application in order to be more "SEO Friendly". Even if it was out the scope of the article itself, it gave me a lot of inspiration and motivation about writing a new article on the subject.

So in the scope of this new tutorial, we will setup the logic and the architecture that will allow us to build an application that is 100% Multilingual, SEO Friendly and Scalable.

As usual, I will use the example of a blog website, which is easy to understand and doesn't have too many features so it is easy for me to write this article, and for you to understand the actual result.

First of all, you can find a working example of what I'll describe here on this github repo. Simply clone the repo, composer install, artisan migrate, db:seed and you'll be able to see how it works in a real life demo :)

The features I wanted as must:

  1. Having the Language Code in the URL
  2. Force the language code to appear at the beginning of the URL
  3. Write less code to do so
  4. Having localized models, meaning : you can write the same blog post in several languages.
  5. Having the ability to add as many language as you want through config file
  6. Translate the blog post's slugs
  7. Having localized URL's for both static pages and Eloquent Models (thanks to slugs)
  8. Updating the slug of a page on the fly when switching language

Ready to dig in ? I know you are !

1. Installing Packages

We need two packages to achieve what we need : one to build slugs based on the post title and one to store translations of our posts.

Quick note : Since I started working on this, I searched for quite a lot of packages to manage Eloquent models translation, and that can work in parallel with the sluggable package. And after months of research, I came to the conclusion that dimsav/laravel-translatable is the package that suits our needs the most.

Sluggable Package

Add the package to your app : composer require cviebrock/eloquent-sluggable:^4.1

Now add the provider to your config/app.php file

'providers' => [
    // ...
    Cviebrock\EloquentSluggable\ServiceProvider::class,
];

Translatable Package

Add the package to your app : composer require dimsav/laravel-translatable

Now add the provider to your config/app.php file

'providers' => [
    // ...
    Dimsav\Translatable\TranslatableServiceProvider::class,
];

Generate the configuration files with php artisan vendor:publish

2. Setting up our Models and Migrations

With a fresh Laravel installation, you already have a User model, but we will not use it here, so let's create the Post model that will be used to query the articles of the blog.

You must know that each model of you application that will contain translatable content will have its own Translation model, and its own translations database table.

Post Model

php artisan make:model Post -m (adding "-m" to the command will generate a migration file).

We won't need to change the migration file that was just generated as the properties of the post (title, slug, content text) will be stored in another table dedicated to translations.

Quickly edit the app/Post.php file and add some code from Translatable package:

// app/Post.php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Dimsav\Translatable\Translatable;

class Post extends Model
{
    use Translatable;

    public $translatedAttributes = ['title', 'slug', 'content'];

    protected $fillable = ['title', 'slug', 'content'];

}

PostTranslation Model

As for the Post model, create another model dedicated to Post Translations with the command php artisan make:model PostTranslation -m and set the content like this:

// app/PostTranslation.php

namespace App;

use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Model;

class PostTranslation extends Model
{
    use Sluggable;

    public $timestamps = false;
    protected $fillable = ['title', 'slug', 'content'];

    /**
     * Return the sluggable configuration array for this model.
     *
     * @return array
     */
    public function sluggable()
    {
        return [
            'slug' => [
                'source' => 'title'
            ]
        ];
    }
}

And the migration file:

/**
 * Run the migrations.
 *
 * @return void
 */
public function up()
{
    Schema::create('post_translations', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('post_id')->unsigned();
        $table->string('locale')->index();

        // The actual fields to store the content of your entity. You can add whatever you need.
        $table->string('title');
        $table->string('slug')->unique();
        $table->text('content');

        $table->unique(['post_id', 'locale']);
        $table->timestamps();
    });
}
/**
 * Reverse the migrations.
 *
 * @return void
 */
public function down()
{
    Schema::dropIfExists('post_translations');
}

You can now run php artisan migrate :)

Note that we ask the PostTranslation model to be slugged. And this actually makes sense because the slug is generated alongside the title of the model, which is stored in the post_translations table, because of our translations logic.

Ok ! We have a working logic to have translated content. But we still need to setup some logic to display your application views in the correct language, build a language switcher and manage the routes.

3. Defining the available languages of your app

The translatable package offers a new config file : config/translatable.php where you can list the different locales of your app.

Update the locales array :

'locales' => [
    'en' => 'English',
    'fr' => 'Français',
],

4. Managing Laravel's app locale

This was the whole point of my previous article and the problem was to set the locale without having the language code in the URL. Now, we want the exact opposite! We want to have the language code at the beginning of the URL, and use that to set Laravel locale.

1. Middleware

First, we will create a new Middleware in which all requests will be analyzed and modified if needed. The goal of the middleware will be to force the language to appear as the first segment of the URL.

Create a new file : app/Http/Middleware/Language.php

// app/Http/Middleware/Language.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;

class Language
{
    public function handle(Request $request, Closure $next)
    {
        // Check if the first segment matches a language code
        if (!array_key_exists($request->segment(1), config('translatable.locales')) ) {

            // Store segments in array
            $segments = $request->segments();

            // Set the default language code as the first segment
            $segments = array_prepend($segments, config('app.fallback_locale'));

            // Redirect to the correct url
            return redirect()->to(implode('/', $segments));
        }

        return $next($request);
    }
}

As you can see with above code, we recreate the whole URL path if the language code is missing. That way, you will always have a language defined.

Do not forget to add the middleware to the Kernel.php file:

// app/Http/Kernel.php

protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\Language::class, // Here it is
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

    'api' => [
        'throttle:60,1',
        'bindings',
    ],
];

2. Let Laravel know that you want to set the locale according to the URL

In order to do that, we need to ask Laravel to setLocale() with the code in the first segment of the URL. There is no better place to do that than the AppServiceProvider.

Edit AppServiceProvider.php and add this in the boot method:

// app/Providers/AppServiceProvider.php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Http\Request;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot(Request $request)
    {
        // Set the app locale according to the URL
        app()->setLocale($request->segment(1));
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

Laravel is now ready to work the correct language, according to what is specified by the URL.

5. Working with Routes

If you visit now your application, you won't see anything because you haven't set any routes, except maybe the default routes of the Laravel boilerplate template.

Don't you think it would be a pain to edit all your routes in the routes/web.php file to have a language variable ? And to specify this variable in all the links in your HTML ? Yeah, me too, and that's why the RouteServiceProvider is for ! Head to app/Providers/RouteServiceProvider.php !

//app/Providers/RouteServiceProvider.php

// ...
use Request;

class RouteServiceProvider extends ServiceProvider
{

    // ...

    protected function mapWebRoutes()
    {
        $locale = Request::segment(1);

        Route::group([
            'middleware' => 'web',
            'namespace' => $this->namespace,
            'prefix' => $locale
        ], function ($router) {
            require base_path('routes/web.php');
        });
    }

    // ...

}

And basically.. That's it ! You can try and visit your app !

I know, I know, we still need to build some things, and I'll explain how to use this architecture

Translated URL's

As the app locale is set from the AppServiceProvider, you can use translations directly in the routes/web.php file. Imagine you want a static page to be translated:

www.mysite.com/en/about
www.mysite.com/fr/a-propos

Two different URL's using the same view in different language? Easy !! You first need to create a language file to store translations, in the hardcoded laravel way.

I store my route names in a language file here resources/lang/en/routes.php and resources/lang/fr/routes.php

Of course you have one file per language in your app.

You can now do things like this in your routes file:

Route::get(trans('routes.about'), ['as' => 'about', 'uses' => 'PageController@getAboutPage']);

And from your language file:

// resources/lang/en/routes.php
return [
    'about' => 'about',
    // other routes name
];
// resources/lang/fr/routes.php
return [
    'about' => 'a-propos',
    // other routes name
];

Guessing you have your "about" link in the header or in the footer, it's super easy to create the correct link using the route's name!

// layout.blade.php
{{ route('about') }}

Could it be more simple than that ?? :D Obviously not.

List of Blog Posts

We saw how to display static pages with hardcoded content and harcoded URL's, but what about our blog posts ?

Well first, you need some in your database ! Check out this command Class : https://github.com/mydnic/Laravel-Multilingual-SEO-Example/blob/master/app/Console/Commands/InsertDummyPosts.php

In your view, there's actually nothing special to do.. 

List View:

// routes/web.php
Route::get('/', ['as' => 'home', 'uses' => 'PostController@index']);
// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    public function index()
    {
        $posts = Post::latest()->get();
        return view('blog')
            ->with('posts', $posts);
    }
}
// resources/views/blog.blade.php
@foreach ($posts as $post)
    <p class="panel panel-default">
        <p class="panel-heading">
            <a href="{{ route('post.show', $post->slug) }}">{{ $post->title }}</a>
        </p>

        <p class="panel-body">
            {{ $post->content }}
        </p>
        <p class="panel-footer text-right">
            <a href="{{ route('post.show', $post->slug) }}">
                {{ trans('app.view_more') }}
            </a>
        </p>
    </p>
@endforeach

As you see, you display your blog posts list as you would normally do. That's a very good thing because it's very easy to integrate our multilingual solution in an existing application.

Displaying a single post is a bit more complicated.

Show Post

// routes/web.php
Route::get('post/{slug}', ['as' => 'post.show', 'uses' => 'PostController@show']);
// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    // ...

    public function show($slug)
    {
        // This is the only difference you need be aware of
        $post = Post::whereTranslation('slug', $slug)->firstOrFail();

        return view('post')
            ->with('post', $post);
    }
}
// resources/views/post.blade.php
<h1>{{ $post->title }}</h1>

<div class="panel panel-default">

    <div class="panel-body">
        {{ $post->content }}
    </div>
</div>

As we store the slug in another table, you cannot simply use the ->where() method, but instead use ->whereTranslation() which actually makes sense !

There is only one more thing we need to do : a language switcher!

6. Language Switcher

I'll use the same one I've build in my previous blog post.

Add a new route.

// routes/web.php
Route::get('lang/{language}', ['as' => 'lang.switch', 'uses' => 'LanguageController@switchLang']);

And create a new controller : php artisan make:controller LanguageController

Where the magic happens:

// app/Http/Controllers/LanguageController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Lang;

class LanguageController extends Controller
{
    public function switchLang(Request $request, $lang)
    {
        // Store the URL on which the user was
        $previous_url = url()->previous();

        // Transform it into a correct request instance
        $previous_request = app('request')->create($previous_url);

        // Get Query Parameters if applicable
        $query = $previous_request->query();

        // In case the route name was translated
        $route_name = app('router')->getRoutes()->match($previous_request)->getName();

        // Store the segments of the last request as an array
        $segments = $previous_request->segments();

        // Check if the first segment matches a language code
        if (array_key_exists($lang, config('translatable.locales'))) {

            // If it was indeed a translated route name
            if ($route_name && Lang::has('routes.' . $route_name, $lang)) {

                // Translate the route name to get the correct URI in the required language, and redirect to that URL.
                if (count($query)) {
                    return redirect()->to($lang . '/' .  trans('routes.' . $route_name, [], $lang) . '?' . http_build_query($query));
                }

                return redirect()->to($lang . '/' .  trans('routes.' . $route_name, [], $lang));
            }

            // Replace the first segment by the new language code
            $segments[0] = $lang;

            // Redirect to the required URL
            if (count($query)) {
                return redirect()->to(implode('/', $segments) . '?' . http_build_query($query));
            }

            return redirect()->to(implode('/', $segments));
        }

        return redirect()->back();
    }

}

And finally, in your layout HTML:

// resources/views/layouts/app.blade.php
<li class="dropdown">
    <a href="#" class="dropdown-toggle" data-toggle="dropdown">
        {{ app()->getLocale() }} <i class="fa fa-caret-down"></i>
    </a>
    <ul class="dropdown-menu">
        @foreach (config('translatable.locales') as $lang => $language)
            @if ($lang != app()->getLocale())
                <li>
                    <a href="{{ route('lang.switch', $lang) }}">
                        {{ $language }}
                    </a>
                </li>
            @endif
        @endforeach
    </ul>
</li>

AND THAT'S ABOUT IT

Waow, that was a long tutorial... I hope it will help you !


Actually... There is one more thing we need to do...

If you test your app now, and you visit a blog post page, if you switch lang, the language code in the URL is updated, and so is the content, but the actual slug remains in the previous language. Not Good!

That's a logic we need to add at the level of the PostController.

Three simple additional lines are need.

// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    // ...

    public function show($slug)
    {
        $post = Post::whereTranslation('slug', $slug)->firstOrFail();

        // New Code
        if ($post->translate()->where('slug', $slug)->first()->locale != app()->getLocale()) {
            return redirect()->route('post.show', $post->translate()->slug);
        }

        return view('post')
            ->with('post', $post);
    }
}

You can thus add this redirect logic to any method that could use this "on the fly" redirect.


That's it for me! Please comment below if you have any remark or if I have made some mistakes... Don't forget that a working example of what I explained is available here. Pull requests are welcomed !

See you soon !