Courses

Multi-Language Laravel 11: All You Need to Know

spatie/laravel-translatable

Summary of this lesson:
- Use JSON column for storing translations
- Add HasTranslations trait to models
- Simplify translation handling with single table
- Automatically retrieve current locale translations
- Create and update translations with minimal configuration

Instead of creating multiple tables and handling translations via a different table, this package uses a single table and a JSON column to store the translations.


Installation

Installation guide for this package is really simple and consists only of two steps:

Require the package via composer:

composer require spatie/laravel-translatable

And for the models you want to translate add the Spatie\Translatable\HasTranslations trait with $translatable property:

Model

use Spatie\Translatable\HasTranslations;
 
class Post extends Model
{
use HasTranslations;
 
public $translatable = ['title'];
}

That is it! Now if you set up the database column title to be a JSON column (or TEXT in unsupported databases), you can start using the package.


Usage

Here's a quick example of how we used this package:

Migration

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->dateTime('publish_date')->nullable();
$table->json('title'); // <--- JSON column for title
$table->json('post'); // <--- JSON column for post
$table->softDeletes();
$table->timestamps();
});

app/Models/Post.php

use Spatie\Translatable\HasTranslations;
 
// ...
 
class Post extends Model
{
use SoftDeletes;
use HasTranslations;
 
public $translatable = ['title', 'post'];
 
protected $fillable = [
'user_id',
'publish_date',
'title',
'post'
];
}

app/Http/Controllers/PostController.php

 
use App\Models\Post;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
 
class PostController extends Controller
{
public function index()
{
$posts = Post::all();
 
return view('posts.index', compact('posts'));
}
 
public function create()
{
$authors = User::pluck('name', 'id');
 
return view('posts.create', compact('authors'));
}
 
public function store(Request $request): RedirectResponse
{
$rules = [
'publish_date' => ['nullable', 'date'],
'author_id' => ['required', 'numeric'],
];
// Adding validation for each available locale
foreach (config('app.supportedLocales') as $locale) {
$rules += [
'title.' . $locale => ['required', 'string'],
'post.' . $locale => ['required', 'string'],
];
}
 
$this->validate($request, $rules);
 
$post = Post::create([
'user_id' => $request->input('author_id'),
'publish_date' => $request->input('publish_date'),
'title' => $request->input('title'), // <-- This will be an array of translations
'post' => $request->input('post'), // <-- This will be an array of translations
]);
 
return redirect()->route('posts.index');
}
 
public function edit(Post $post)
{
$authors = User::pluck('name', 'id');
 
return view('posts.edit', compact('post', 'authors'));
}
 
public function update(Request $request, Post $post): RedirectResponse
{
$rules = [
'publish_date' => ['nullable', 'date'],
'author_id' => ['required', 'numeric'],
];
// Adding validation for each available locale
foreach (config('app.supportedLocales') as $locale) {
$rules += [
'title.' . $locale => ['required', 'string'],
'post.' . $locale => ['required', 'string'],
];
}
 
$this->validate($request, $rules);
 
$post->update([
'user_id' => $request->input('author_id'),
'publish_date' => $request->input('publish_date'),
'title' => $request->input('title'), // <-- This will be an array of translations
'post' => $request->input('post'), // <-- This will be an array of translations
]);
 
return redirect()->route('posts.index');
}
 
public function destroy(Post $post): RedirectResponse
{
$post->delete();
 
return redirect()->route('posts.index');
}
}

And finally the views:

resources/views/posts/index.blade.php

<table class="w-full">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Excerpt</th>
<th>Published at</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($posts as $post)
<tr>
<td>{{ $post->id }}</td>
<td>{{ $post->title }}</td> {{-- As you can see, we get just the title. The package handles the rest. --}}
<td>{{ Str::of($post->post)->limit() }}</td> {{-- As you can see, we get just the post. The package handles the rest. --}}
<td>{{ $post->publish_date ?? 'Unpublished' }}</td>
<td>
{{-- ... --}}
</td>
</tr>
@endforeach
</tbody>
</table>

As you see - we didn't have to specify which language we want to display as default. It is done by the package itself!

On create we will make an array of translations for each field:

resources/views/posts/create.blade.php

<form action="{{ route('posts.store') }}" method="POST">
@csrf
 
<div class="mb-4">
<label for="author_id" class="sr-only">Author</label>
<select name="author_id" id="author_id"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('author_id') border-red-500 @enderror">
<option value="">Select author</option>
@foreach($authors as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
@error('author_id')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
@foreach(config('app.supportedLocales') as $locale) {{-- Looping through all available locales to create an array for `title` and `post` fields with locales --}}
<fieldset class="border-2 w-full p-4 rounded-lg mb-4">
<label>Text for {{ $locale }}</label>
<div class="mb-4">
<label for="title[{{$locale}}]" class="sr-only">Title</label>
<input type="text" name="title[{{$locale}}]" id="title[{{$locale}}]"
placeholder="Title"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('title') border-red-500 @enderror"
value="{{ old('title.'. $locale) }}">
@error('title.'.$locale)
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
<div class="">
<label for="post[{{$locale}}]" class="sr-only">Body</label>
<textarea name="post[{{$locale}}]" id="post[{{$locale}}]" cols="30" rows="4"
placeholder="Post"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('post'.$locale) border-red-500 @enderror">{{ old('post'.$locale) }}</textarea>
@error('post.'.$locale)
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
</fieldset>
@endforeach
 
<div class="mb-4">
<label for="publish_date" class="sr-only">Published at</label>
<input type="datetime-local" name="publish_date" id="publish_date"
placeholder="Published at"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('publish_date') border-red-500 @enderror"
value="{{ old('publish_date') }}">
@error('publish_date')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
<div>
<button type="submit" class="bg-blue-500 text-white px-4 py-3 rounded font-medium w-full">
Create
</button>
</div>
</form>

The same for editing, but we will have to load specific translations for each field:

resources/views/posts/edit.blade.php

<form action="{{ route('posts.update', $post->id) }}" method="POST">
@csrf
@method('PUT')
 
<div class="mb-4">
<label for="author_id" class="sr-only">Author</label>
<select name="author_id" id="author_id"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('author_id') border-red-500 @enderror">
<option value="">Select author</option>
@foreach($authors as $id => $name)
<option value="{{ $id }}" @selected(old('author_id', $post->user_id) === $id)>{{ $name }}</option>
@endforeach
</select>
@error('author_id')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
@foreach(config('app.supportedLocales') as $locale) {{-- Looping through all available locales to create an array for `title` and `post` fields with locales --}}
<fieldset class="border-2 w-full p-4 rounded-lg mb-4">
<label>Text for {{ $locale }}</label>
<div class="mb-4">
<label for="title[{{$locale}}]" class="sr-only">Title</label>
<input type="text" name="title[{{$locale}}]" id="title[{{$locale}}]"
placeholder="Title"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('title') border-red-500 @enderror"
value="{{ old('title.'. $locale, $post->getTranslation('title', $locale)) }}">
@error('title.'.$locale)
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
<div class="">
<label for="post[{{$locale}}]" class="sr-only">Body</label>
<textarea name="post[{{$locale}}]" id="post[{{$locale}}]" cols="30" rows="4"
placeholder="Post"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('post'.$locale) border-red-500 @enderror">{{ old('post'.$locale, $post->getTranslation('post', $locale)) }}</textarea>
@error('post.'.$locale)
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
</fieldset>
@endforeach
 
<div class="mb-4">
<label for="publish_date" class="sr-only">Published at</label>
<input type="datetime-local" name="publish_date" id="publish_date"
placeholder="Published at"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('publish_date') border-red-500 @enderror"
value="{{ old('publish_date', $post->publish_date) }}">
@error('publish_date')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
<div>
<button type="submit" class="bg-blue-500 text-white px-4 py-3 rounded font-medium w-full">
Update
</button>
</div>
</form>

In edit, you will see $post->getTranslation('title', $locale) and $post->getTranslation('post', $locale). This is how we get the translation for a specific locale. We can also use $post->title and $post->post but this will return the translation for the current locale which is not good if we want to edit all the locales.


Repository: https://github.com/LaravelDaily/laravel11-localization-course/tree/lesson/modelPackages/spatie

Previous: Translating Models - With Livewire
avatar

I want add thing if want add more than languages so you need add tab like this ex: @foreach (config('locales.languages') as $key => $val)

  • {{$val['name']}}
  • @endforeach
    </ul>
    
    @foreach (config('locales.languages') as $key => $val)

    Meta-Bereich

    ** and if want add auto transtion with deepl **
    				<?php
    

    namespace App\Translators;

    use GuzzleHttp\Client; use Illuminate\Support\Facades\Cache;

    class DeepLTranslator { public function translate($text, $target_lang = 'de') { // dd($text); if (\is_array($text)){ $text = $text['de']; } // Check if the translation is already in the cache $cachedTranslation = Cache::get("translation_{$text}_{$target_lang}"); if ($cachedTranslation) { return $cachedTranslation; }

        $client = new Client();
    

    try{ $response = $client->post('https://api.deepl.com/v2/translate', [ 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', 'User-Agent' => 'name' ], 'form_params' => [ 'auth_key' => env('DEEPL_API_KEY'), 'text' => $text, 'target_lang' => $target_lang ] ]); } catch (\Exception $e) { return $e->getMessage(); }

        $result = json_decode($response->getBody()->getContents());
        // Store the translation in the cache
        Cache::put("translation_{$text}_{$target_lang}", $result->translations[0]->text, 1440);
        return $result->translations[0]->text;
    }
    

    }

    avatar
    You can use Markdown
    avatar

    in index blade nothing is shows when applying the package

    avatar

    can you explain what the issue is a bit more?

    avatar

    im trying to use spatie/laravel-translatable to translate model called ProductCategory, in create and edit forms worked fine, but when i tried to display the traslated fileds according to the current local it not showin anything at all. hit: im using quick admin panel generator, so the default generated code for locale inside config/panel.php not in config/app.php so that is the only bit of code i have changed in the above lesson

    avatar

    Have you added:

     public $translatable = ['title', 'post'];
    

    To your models?

    If yes, I would just dump what the models returns or what the database contains.

    avatar

    Model ProductCategory:

    use Spatie\Translatable\HasTranslations;

    class ProductCategory extends Model implements HasMedia { use SoftDeletes, InteractsWithMedia, Auditable, HasFactory, HasTranslations;

    public $translatable = ['name','description'];
    
    avatar

    #attributes: array:6 [▼ "id" => 1 "name" => "{"English":"Oil","Arabic":"\u0632\u064a\u0648\u062a"}" "description" => "{"English":"Officiis qui enim al","Arabic":"\u0632\u064a\u0648\u062a"}" "created_at" => "2023-05-28 07:38:31" "updated_at" => "2023-05-28 07:38:31" "deleted_at" => null

    avatar

    It seems that the package works, yet I'm a little bit confused why it has full language names as it should be en or ar instead of full names. So please check how you are saving the data :)

    avatar

    Fixed it, and here was the problem this is panel.php content return [ 'date_format' => 'Y-m-d', 'time_format' => 'H:i:s', 'primary_language' => 'en', 'available_languages' => [ 'en' => 'English', 'ar' => 'Arabic', ], 'registration_default_role' => '2',

    ];

    i was saveing the value of the locale instead of key @foreach(config('panel.available_languages')) as $locale the correct is: @foreach(config('panel.available_languages')) as $key => $value then use the key instead of using the value.

    Thanks a lot of quick responding

    Great Team

    avatar
    You can use Markdown
    avatar
    You can use Markdown