Implement a translation system into your Laravel project with Inertia and Vue
Tags: laravel, vue, tutorial
Published: 2024-02-12 16:55:34

TL;DR: How to quickly set up a translation system in a Laravel project with Inertia and Vue.

&nbsp;
&nbsp;

A sample Laravel project can be found on this [Github Repository](https://github.com/capsulescodes/articles).
Find out more on [Capsules](https://capsules.codes) or [X](https://x.com/capsulescodes).

&nbsp;
&nbsp;

The Laravel framework provides a default localization system, but it requires some additions for the proper functioning of a web tool using the Laravel Inertia and Vue technologies. This article addresses this topic.

&nbsp;
&nbsp;

Starting from a basic Laravel Inertia Vue Tailwind project, it is not yet adapted for internationalization. Proof of this is simply that the `lang` folder is missing. The following steps establish the foundations of a multilingual tool.

&nbsp;
&nbsp;

First, add the default Laravel `lang` folder to the `template` project along with a translation file. For example, the language of Molière :

&nbsp;

```bash
cd template
mkdir lang
```

&nbsp;


`lang/fr.json`


```json
{
    "Hello world!" : "Bonjour le monde!",
	"This is a translation" : "Ceci est une traduction",
    "Maintenance mode activated" : "Le mode maintenance est activé"
}
```

&nbsp;
&nbsp;

Three translations are accessible. The English translations, visible in the Vue components, are the keys, while the French translations are the values.

&nbsp;
&nbsp;

Add the different languages that will be part of the site in the file `config/app.php`. In this article, it concerns `en` and `fr`.

&nbsp;
&nbsp;

`config/app.php`

```php
/*
|--------------------------------------------------------------------------
| Application Available Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the available locales that will be used
| by the translation service provider. You are free to set this array
| to any of the locales which will be supported by the application.
|
*/

'available_locales' => [ 'en', 'fr' ],
```
- This configuration will be useful to us during the implementation of the language change buttons.

&nbsp;
&nbsp;

The new informations can now be injected into the shared data in Inertia's `HandleInertiaRequest` middleware.

&nbsp;
&nbsp;

`app/Http/Middleware/HandleInertiaRequests.php`

```php
<?php

namespace App\Http\Middleware;

use Inertia\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\File;

class HandleInertiaRequests extends Middleware
{
    public function share( Request $request ) : array
    {
        $file = lang_path( App::currentLocale() . ".json" );

        return array_merge( parent::share( $request ), [
            'csrf' => csrf_token(),
            'locale' => App::currentLocale(),
            'locales' => config( 'app.available_locales' ),
            'translations' => File::exists( $file ) ? File::json( $file ) : []
        ] );
    }
}
```
- `locale` represents the current language.
- `locales` represents the different available languages, as evidenced by `config( 'app.available_locales' )`.
- `translations` groups the available translations from the JSON files located in the `lang` directory and linked to the current language. If no file exists, the returned translation array will be empty.

&nbsp;
&nbsp;

Here's how to check the content of the shared data with the client :

&nbsp;
&nbsp;

`routes/web.php`

```php
<?php

use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

App::setLocale( 'fr' );

Route::get( '/', fn() => dd( Inertia::getShared() ) );
```

```
array:5 [▼ // routes/web.php:10
  "errors" => Closure() {#307 ▶}
  "csrf" => "QTGHRkM83KysIS7htTNEWfZ9sC6Cs7U20i6kSSeF"
  "locale" => "fr"
  "locales" => array:2 [▼
    0 => "en"
    1 => "fr"
  ]
  "translations" => array:2 [▼
    "Hello world!" => "Bonjour le monde!"
    "This is a translation" => "Ceci est une traduction"
  ]
]
```
- Modify the language using `App::setLocale('fr')` to identify the different translations. In this case, the other possibilities will return an empty array for the `translations`.

&nbsp;
&nbsp;

The `web.php` file can be configured correctly now.

&nbsp;
&nbsp;

`routes/web.php`

```php
<?php

use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

App::setLocale( 'fr' );

Route::get( '/', fn() => Inertia::render( 'Welcome' ) )->name( 'welcome' );
```

&nbsp;
&nbsp;

On the client side, specifically in Vue, you need to set up a `composable` that takes into account the current `locale` to display the correct `translation` found in the translations array transmitted from the server.

&nbsp;
&nbsp;

```bash
mkdir resources/js/composables
cd resources/js/composables
```

&nbsp;
&nbsp;

`resources/js/composables/trans.js`

```javascript
import { usePage } from '@inertiajs/vue3';

export function useTrans( value )
{
    const array = usePage().props.translations;

    return array[ value ] != null ? array[ value ] : value;
}
```
- `useTrans` returns the translation if it exists, otherwise, it returns the default English phrase.

&nbsp;
&nbsp;

It is now possible to implement the translations added at the beginning of this article in the `Welcome.vue` file by replacing "Capsules Codes" with "Hello world!" and importing `useTrans`.

&nbsp;
&nbsp;

`resources/js/pages/Welcome.vue`

```vue
<script setup>

import { useTrans } from '/resources/js/composables/trans';

import logotype from '/public/assets/capsules-logotype.svg';

</script>

<template>

    <div class="w-screen h-screen flex flex-col items-center justify-center text-center">

        <img class="w-24 h-24" v-bind:src="logotype" v-bind:alt="'Capsules Codes Logotype'">

        <h1 class="mt-4 text-6xl font-bold select-none header-mode" v-text="useTrans( 'Hello world!' )" />

    </div>

</template>
```

&nbsp;
&nbsp;

![Capsules Translations Image 1](https://capsules.codes/storage/canvas/images/ZQsZzcxKosHw21PdUilwyLbaQVPPVTfZvhNmK8RO.png)

&nbsp;
&nbsp;

It's time to implement the navigation bar, listing the different language choices, directly from the `Welcome.vue` file.

&nbsp;
&nbsp;

`resources/js/pages/Welcome.vue`

```vue
<script setup>

import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
import { useTrans } from '/resources/js/composables/trans';

import logotype from '/public/assets/capsules-logotype.svg';

const locales = computed( () => usePage().props.locales );
const index = computed( () => locales.value.findIndex( value => value == usePage().props.locale ) + 1 );
const language = computed( () => locales.value[ index.value % locales.value.length ] );

</script>

<template>

    <div class="absolute h-12 w-full flex items-center justify-center">

        <a v-if=" locales.length > 1 " class="rounded-md outline-none hover:bg-slate-50 text-sm font-medium" v-bind:href="`/${language}`" v-text="`/ ${language}`" />

    </div>

    <div class="w-screen h-screen flex flex-col items-center justify-center text-center">

        <img class="w-24 h-24" v-bind:src="logotype" v-bind:alt="'Capsules Codes Logotype'">

        <h1 class="mt-4 text-6xl font-bold select-none header-mode" v-text="useTrans( 'Hello world!' )" />

    </div>

</template>
```
- The computed constant `locales` returns the available languages via Inertia.
- The computed constant `index` represents the index following the current locale.
- The computed constant `language` represents the language following the current language. In this case, if we have `fr` , `language` will represent `en` . If there is only one language, nothing is displayed. If there are three languages, each language will scroll one after the other.

&nbsp;
&nbsp;

The language displayed in the top bar is, then, the language that is not used on the page. The goal now is to apply this choice to the server-side locale. The `<a>` tag then sends a GET request to `/fr` or `/en` depending on the `language`.

&nbsp;
&nbsp;

To allow the server to understand this and change the `locale` via this process, a middleware is necessary : `SetLocale` .

&nbsp;
&nbsp;

`app/Http/Middleware/SetLocale.php`

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;

class SetLocale
{
    public function handle( Request $request, Closure $next ) : Response
    {
        if( in_array( $request->segment( 1 ), config( 'app.available_locales' ) ) && $request->segment( 1 ) !== App::currentLocale() ) Session::put( 'locale', $request->segment( 1 ) );

        App::setLocale( Session::get( 'locale', App::currentLocale() ) );

        URL::defaults( [ 'locale' => App::currentLocale() ] );

        return $next( $request );
    }
}
```
- The first condition checks if the locale is among the available locales.
- The second condition checks if the given locale is different from the current locale.
- `URL::defaults( [ 'locale' => App::currentLocale() ] );` allows adding the locale to the URL.

&nbsp;
&nbsp;

The role of the `SetLocale` middleware is to initialize or change the locale, as well as to add it to the URL.

&nbsp;
&nbsp;

This middleware can then be added to the `Kernel` file. The position of the middleware is important but depends only on its usefulness. It is useful to place it before the `PreventRequestsDuringMaintenance` maintenance middleware to also benefit from translation on the maintenance page during maintenance.

&nbsp;
&nbsp;

`app/Http/Kernel.php`

```php
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
    protected $middleware = [
        ...
        \App\Http\Middleware\SetLocale::class,
        \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
        ...
    ];
    ...
```

&nbsp;
&nbsp;

A new prefix, a new route, and a fallback are necessary in the `web.php` file. The new route aims to redirect to the previous route if it exists. Otherwise, it returns to the default route, `welcome` .

&nbsp;
&nbsp;

`routes/web.php`

```php
<?php

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::prefix( '{locale}' )->where( [ 'locale' => '[a-zA-Z]{2}' ] )->group( function()
{
	Route::get( '', fn() => redirect()->route( Route::getRoutes()->match( Request::create( URL::previous() ) )->getName() ) ?? 'welcome' );

	Route::get( 'welcome', fn() => Inertia::render( 'Welcome' ) )->name( 'welcome' ); 
} );

Route::fallback( fn() => redirect()->route( 'welcome' ) );
```
- `Route::prefix( '{locale}' )` as its name indicates, adds a prefix to each route. Here, it will be the locale.
- This locale must adhere to `where(['locale' => '[a-zA-Z]{2}'])`, which is equivalent to two letters between `a` and `Z`.
- As the routes `''` and `'/'` are the same, it is necessary to redirect the initial route welcome to `'welcome'`.
- `App::setLocale( 'fr' );` can now be removed.
- `Route::fallback( fn() => redirect()->route( 'welcome' ) );` indicates that if no route matches the given request, it will redirect to the `'welcome'` route. This is a way to handle errors and avoid a `404` page in this case.
- It is important not to specify a name for the route for changing the locale, or an infinite loop could occur in its redirection.

&nbsp;
&nbsp;

The translation system is now functional. 🎉

&nbsp;
&nbsp;

To avoid having to add the locale to every href reference, among other methods, another function can be added to the composable `trans.js`: `useRoute`.

&nbsp;
&nbsp;

`resources/js/compsables/trans.js`

```javascript
import { usePage } from '@inertiajs/vue3';

...

export function useRoute( value = null )
{
    return `/${usePage().props.lang}${value ?? ''}`;
}
```

&nbsp;

```vue
import { useRoute, useTrans } from '~/composables/trans';

<a v-bind:href="useRoute( `/welcome` )"><span v-text="useTrans( 'Welcome' )" />
```

&nbsp;
&nbsp;

Now that the routes have a prefix, they can be accessed from their closure.

&nbsp;
&nbsp;

`routes/web.php`

```php
<?php

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::prefix( '{locale}' )->where( [ 'locale' => '[a-zA-Z]{2}' ] )->group( function()
{
    ...

    Route::get( 'translate', fn( string $locale ) => dd( __( "This is a translation", [], $locale ) ) );

    ...
} );

...
```

&nbsp;

```bash
"Ceci est une traduction" // routes/web.php:13
```

&nbsp;
&nbsp;

In case of maintenance, as indicated earlier, the locale is indeed assigned, but the translations will not be sent because the `PreventRequestDuringMaintenance` middleware will be called before the `HandleInertiaRequest` middleware. Therefore, you need to inject them manually into the `Handler`.

&nbsp;
&nbsp;

`app/exceptions/handler.php`

```php
use Symfony\Component\HttpFoundation\Response;
use Inertia\Response as InertiaResponse;
use Inertia\Inertia;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\File;

public function render( $request, Throwable $exception ) : Response | InertiaResponse
{
    $response = parent::render( $request, $exception );

    if( $response->status() === 503 )
    {
        Inertia::share( 'locale', App::currentLocale() );
        Inertia::share( 'translations', File::exists( lang_path( App::currentLocale() . ".json" ) ) ? File::json( lang_path( App::currentLocale() . ".json" ) ) : [] );

		return Inertia::render( 'Error' )->toResponse( $request )->setStatusCode( $response->status() );
    }

    return $response;
}
```

&nbsp;
&nbsp;

`resources/js/pages/Error.vue`

```vue
<script setup>

import { useTrans } from '/resources/js/composables/trans';

</script>

<template>

    <div class="w-screen h-screen flex items-center justify-center text-center space-y-8">

        <h1 class="text-6xl font-bold select-none header-mode" v-text="useTrans( 'Maintenance mode activated' )" />

    </div>

</template>
```

&nbsp;
&nbsp;

```bash
php artisan down
```

&nbsp;
&nbsp;

![Capsules Translations Image 2](https://capsules.codes/storage/canvas/images/ClnJvXG1sTWs9A90NmW1fglLw8xnLfcxwIKnbA8J.png)

&nbsp;
&nbsp;

Glad this helped.