Courses

Multi-Language Laravel 11: All You Need to Know

astrotomic/laravel-translatable

Summary of this lesson:
- Use separate translation table for each model
- Create parent and translation models
- Configure translatable attributes
- Handle translations with more complex database structure
- Use unique syntax for retrieving translations by locale

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

Previous: spatie/laravel-translatable
avatar

I would also like to add a tip for validation rules. This package has a helper for their convenient creation. It is described here.

For example, I have global function for that

function lang_rules(array $rules): array
{
  return RuleFactory::make($rules);
}

and in form request

public function rules(): array
{
  return lang_rules([
    'publish_date' => ['nullable', 'date'],

    '{title}' => ['required', 'string', 'max:255'],
    '{body}' => ['required', 'string'],
  ]);
}

with this config options

'rule_factory' => [
    'format' => \Astrotomic\Translatable\Validation\RuleFactory::FORMAT_ARRAY,
    'prefix' => '{',
    'suffix' => '}',
]
👍 2
avatar
You can use Markdown
avatar

Hello, How can I pluck translation model like Blog (title) / BlogTranslation (title_trans)

public function edit(Post $post)
{
    $blog = Blog::pluck('title', "title_trans", 'id');  <- ??? 

    return view('posts.edit', compact('post', 'blog'));
}
avatar

If I'm not mistaken, you can just do:

Blog::pluck('title', 'id');

And it should work, as it overrides the Title with a translated value automatically

avatar

Hi. For such cases I have next trait. It

Blog::joinTranslations()->pluck('title', 'id');
trait WithTranslationsTrait
{
    public function scopeWithTranslations(Builder $query): Builder
    {
        return $query->with([
            'translations' => function ($query) {
                $query->where('locale', app()->getLocale());
            },
        ]);
    }

    public function scopeJoinTranslations(
        Builder $query,
        ?string $modelTable = null,
        ?string $translationsTable = null,
        ?string $modelTableKey = null,
        ?string $translationsTableKey = null
    ): Builder {
        if (!$modelTable) {
            $modelTable = $this->getTable();
        }

        $singularModelTable = Str::singular($modelTable);

        if (!$translationsTable) {
            $translationsTable = $singularModelTable . "_translations";
        }

        $translationsTableKey = (empty($translationsTableKey) ? $singularModelTable . "_id" : $translationsTableKey);
        $modelTableKey = (empty($modelTableKey) ? "id" : $modelTableKey);

        return $query->leftJoin(
            $translationsTable,
            function ($join) use ($modelTable, $translationsTable, $translationsTableKey, $modelTableKey) {
                $join->on("{$translationsTable}.{$translationsTableKey}", '=', "{$modelTable}.{$modelTableKey}")
                    ->where('locale', '=', app()->getLocale());
            }
        );
    }
}
avatar
You can use Markdown
avatar

which best to use astrotomic-laravel-translatable or spatie/laravel-translatable

avatar

There is never "a best to use". There is always "the better one for your situation".

In this case, it really depends on how/what your system looks like. Play around with both and see which feels better.

avatar
You can use Markdown
avatar
You can use Markdown