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

astrotomic/laravel-translatable

This package relies on a separate DB table to contain all of your localized model information. You will have to manually create this table and add the necessary columns. The package will then automatically handle the rest.

In other words, it requires more setup initially but provides a lot of flexibility.


Installation

In the full installation guide we can quickly spot that it's not too complicated to install this package:

Installing package via composer:

composer require astrotomic/laravel-translatable

Publishing the config file:

php artisan vendor:publish --tag=translatable

Adapting the configuration file to our case:

'locales' => [
'en',
'es',
],

This will set the base up for us to use.


Usage

After the initial setup, we have to adapt our Models to use the translation, which will require quite a bit of coding (but it's not too complicated).

Use this package, the biggest difference is in Migrations and Models:

Migration

Schema::create('posts', function (Blueprint $table) { // <-- Our parent table
$table->id();
$table->foreignId('user_id')->constrained();
$table->dateTime('publish_date')->nullable();
$table->timestamps();
$table->softDeletes();
});
 
// Our translations table defined for EACH model that's translatable
Schema::create('post_translations', function (Blueprint $table) {
$table->increments('id');
$table->foreignId('post_id')->constrained();
$table->string('locale')->index();
$table->string('title');
$table->text('post');
 
$table->unique(['post_id', 'locale']);
});

Since we created two new tables, this means that we have to have 2 models:

app/Models/Post.php

use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract;
use Astrotomic\Translatable\Translatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
 
class Post extends Model implements TranslatableContract
{
use Translatable;
use SoftDeletes;
 
// Here we define which attributes are translatable
public $translatedAttributes = ['title', 'post'];
protected $fillable = ['user_id', 'publish_date'];
 
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

And translations model:

app/Models/PostTranslation.php

use Illuminate\Database\Eloquent\Model;
 
class PostTranslation extends Model
{
public $timestamps = false; // <-- We don't need timestamps for translations
protected $fillable = ['title', 'post'];
}

That's it, we have set up our models to use translations. Now it's time to implement it:

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'],
'user_id' => ['required', 'numeric'],
];
// Adding validation for each available locale
foreach (config('translatable.locales') as $locale) {
$rules += [
$locale . '.title' => ['required', 'string'],
$locale . '.post' => ['required', 'string'],
];
}
 
$this->validate($request, $rules);
 
// We should use `$request->validated()` if we are using `FormRequest`
$post = Post::create($request->all());
 
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'],
'user_id' => ['required', 'numeric'],
];
// Adding validation for each available locale
foreach (config('translatable.locales') as $locale) {
$rules += [
$locale . '.title' => ['required', 'string'],
$locale . '.post' => ['required', 'string'],
];
}
 
$this->validate($request, $rules);
 
// We should use `$request->validated()` if we are using `FormRequest`
$post->update($request->all());
 
return redirect()->route('posts.index');
}
 
public function destroy(Post $post): RedirectResponse
{
$post->delete();
 
return redirect()->route('posts.index');
}
}

And 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>
<td>{{ Str::of($post->post)->limit() }}</td>
<td>{{ $post->publish_date ?? 'Unpublished' }}</td>
<td>
{{-- ... --}}
</td>
</tr>
@endforeach
</tbody>
</table>

The create form:

resources/views/posts/create.blade.php

<form action="{{ route('posts.store') }}" method="POST">
@csrf
 
<div class="mb-4">
<label for="user_id" class="sr-only">Author</label>
<select name="user_id" id="user_id"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('user_id') border-red-500 @enderror">
<option value="">Select author</option>
@foreach($authors as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
@error('user_id')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
@foreach(config('translatable.locales') as $locale)
<fieldset class="border-2 w-full p-4 rounded-lg mb-4">
<label>Text for {{ $locale }}</label>
<div class="mb-4">
<label for="{{$locale}}[title]" class="sr-only">Title</label>
<input type="text" name="{{$locale}}[title]" id="{{$locale}}[title]"
placeholder="Title"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error($locale.'.title') border-red-500 @enderror"
value="{{ old($locale.'.title') }}">
@error($locale.'.title')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
<div class="">
<label for="{{$locale}}[post]" class="sr-only">Body</label>
<textarea name="{{$locale}}[post]" id="{{$locale}}[post]" cols="30" rows="4"
placeholder="Post"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error($locale.'.post') border-red-500 @enderror">{{ old($locale.'.post') }}</textarea>
@error($locale.'.post')
<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>

One thing to look at - we have different field names. As per documentation we are using locale[field] format. So we have to use old('locale.field') to get the old value too!

Next is our edit form:

resources/views/posts/edit.blade.php

<form action="{{ route('posts.update', $post->id) }}" method="POST">
@csrf
@method('PUT')
 
{{ $errors }}
 
<div class="mb-4">
<label for="user_id" class="sr-only">Author</label>
<select name="user_id" id="user_id"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error('user_id') border-red-500 @enderror">
<option value="">Select author</option>
@foreach($authors as $id => $name)
<option value="{{ $id }}" @selected(old('user_id', $post->user_id) === $id)>{{ $name }}</option>
@endforeach
</select>
@error('user_id')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
 
@foreach(config('translatable.locales') as $locale)
<fieldset class="border-2 w-full p-4 rounded-lg mb-4">
<label>Text for {{ $locale }}</label>
<div class="mb-4">
<label for="{{$locale}}[title]" class="sr-only">Title</label>
<input type="text" name="{{$locale}}[title]" id="{{$locale}}[title]"
placeholder="Title"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error($locale.'.title') border-red-500 @enderror"
value="{{ old($locale.'.title', $post->{'title:'.$locale}) }}">
@error($locale.'.title')
<div class="text-red-500 mt-2 text-sm">
{{ $message }}
</div>
@enderror
</div>
<div class="">
<label for="{{$locale}}[post]" class="sr-only">Body</label>
<textarea name="{{$locale}}[post]" id="{{$locale}}[post]" cols="30" rows="4"
placeholder="Post"
class="bg-gray-100 border-2 w-full p-4 rounded-lg @error($locale.'.post') border-red-500 @enderror">{{ old($locale.'.post', $post->{'post:'.$locale}) }}</textarea>
@error($locale.'.post')
<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>

Here, we should pay attention to not yet-seen syntax $post->{'post:'.$locale} that's described here. This allows us to get the translated attribute based on the specific locale (in our case it's either en or es). Pretty cool!


Repository: https://github.com/LaravelDaily/laravel11-localization-course/tree/lesson/modelPackages/Astrotomic-content-translation