Back to Course |
Multi-Language Laravel 11: All You Need to Know

spatie/laravel-translatable

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