How To Build An Efficient and SEO Friendly Multilingual Architecture in Laravel - UPDATED GUIDE

How To Build An Efficient and SEO Friendly Multilingual Architecture in Laravel - UPDATED GUIDE


Nov 21 2018, 12:00 in Web Development

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.

Objective

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!

MySQL Pre Requisite

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 πŸ‘

List of Features

Those are the MUST have features that we want to cover in order to make this solution viable.

  • Enforcing the Language Code in the URL
  • Exclude some URL paths from this enforcing
  • Having localized models, meaning: you can write the same blog post in several languages, editable by logged in user
  • Having the ability to add as many languages as you want through a config file
  • Translate the blog post's slugs
  • Having localized URL's for both static pages and Eloquent Models with the translated slugs
  • Updating the slug of a page on the fly when switching language

Setup

0. Base Laravel App

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.

1 Installing Packages

We need one package to generate and handle unique slugs, and another package to store and handle the translations of a single model.

1.1 Laravel Translatable

Documentation

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

1.2 Eloquent Sluggable

Documentation

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"

2. Setup Models

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

3. Setup Migration

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.

4. Route Model Binding

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);
    });
}

List Posts for demo + testing

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:

5. Listing Locales

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.

6. Routing

6.1 Middleware - Enforce the locale in the URL

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:

  • myappdomain.com/ β†’ myappdomain.com/en
  • myappdomain.com/some/route β†’ myappdomain.com/en/some/route

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 = array_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.

6.2 RouteServiceProvider

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 πŸŽ‰

6.3 Set Laravel Locale Automatically

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!

6.4 Translating Static Routes

At this point on your app, you can access

  • example.com/en/post/my-awesome-post
  • example.com/fr/post/mon-incroyable-article

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!

  • example.com/en/post/my-awesome-post
  • example.com/fr/article/mon-incroyable-article

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 your resources/lang/en/routes.php you should use "word only" keys. I haven't tried thousands of use case here but I can imagine that some complicated route URLs and translation keys may give you bugs when switching the locale. In other words, try to keep things simple like in my example. BUT I would love you to try complicated stuff just to inform me about the results πŸ˜„ I just don't want you to get bugs.
  • In the previous tutorial, we heavily used route names to manage the translations of routes. The route name actually needed to be the key in your translation array. But now, I completely ditched that solution to simply use translated route segments. Thus, you can use whatever you want as route names !

7. Language Switcher

7.1 Dropdown Component

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.

7.2 Locale Switcher Route

Register that new route:

Route::name('locale.switch')->get('switch/{locale}', 'LocaleController@switch');

7.3 Locale Switcher Controller

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:

  • grab the URL that you were previously on
  • translate every translatable segment (not the model's slug though)
  • rebuild a new route with the translated segments (if any)
  • redirect to that new URL
  • every query parameters are preserved

At this point, you should get this behavior:

  • example.com/en/post/my-awesome-post β†’ switch locale β†’ example.com/fr/article/my-awesome-post

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

7.3 Update Model's slug on the fly

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;
}

Laravel Nova

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! ❀️


Share:
Like:

Disable AdBlock on this domain and offer me a cup of coffee :)