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

Translating Models: With No Packages

We've covered translations for static text and routes, but what about the Models? What if we have a Blog Post that we want to store in a multi-language format?

There are quite a few ways to do this, but first, let's look at the simplest way without any packages:


Let's Get Started

For this demo, we will create Posts in multiple languages. The idea here will be:

  • Make sure that we have a list of supported locales somewhere (for example in config/app.php as supportedLocales)
  • Create a Post model that will have all the details about the post, except for the title and content
  • Create a PostTranslation model that will have the title and content of the post in a specific language
  • Automatically load the current locale translation of the post when we retrieve the post (with hasOne relation and attribute mutators in our Post model)
  • Validate each translated field for each supported locale
  • Create a Post and its translations in one go

Here's the database schema for our Post and its translations in PostTranslation:

Migration

Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->dateTime('publish_date')->nullable();
$table->foreignId('user_id')->constrained();
$table->softDeletes();
$table->timestamps();
});
 
Schema::create('post_translations', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('locale');
$table->string('title');
$table->longText('post');
$table->softDeletes();
$table->timestamps();
});

Our Post model, which will contain a title and post attributes to always have the current locale translation of the text:

app/Models/Post.php

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
 
class Post extends Model
{
use SoftDeletes;
 
protected $fillable = [
'publish_date',
'user_id',
];
 
protected $casts = [
'publish_date' => 'datetime',
];
 
// Preloading current locale translation at all times
protected $with = [
'defaultTranslation'
];
 
public function title(): Attribute
{
return new Attribute(
// Always making sure that we have current locale title
get: fn() => $this->defaultTranslation->title,
);
}
 
public function post(): Attribute
{
return new Attribute(
// Always making sure that we have current locale post model
get: fn() => $this->defaultTranslation->post,
);
}
 
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
 
public function translations(): HasMany
{
return $this->hasMany(PostTranslation::class);
}
 
public function defaultTranslation(): HasOne
{
// Making sure that we always retrieve current locale information
return $this->translations()->one()->where('locale', app()->getLocale());
}
}

And our PostTranslation model:

app/Models/PostTranslation.php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
 
class PostTranslation extends Model
{
use SoftDeletes;
 
protected $fillable = [
'post_id',
'locale',
'title',
'post',
];
 
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}

Once we have our Models, we can look into our PostController and see how we can create a new post with translations:

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'),
]);
 
// Saving translations for each available locale
foreach (config('app.supportedLocales') as $locale) {
$post->translations()->create([
'locale' => $locale,
'title' => $request->input('title.' . $locale),
'post' => $request->input('post.' . $locale),
]);
}
 
return redirect()->route('posts.index');
}
 
public function edit(Post $post)
{
$authors = User::pluck('name', 'id');
$post->load(['translations']);
 
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'),
]);
 
// Updating translations for each available locale
foreach (config('app.supportedLocales') as $locale) {
$post->translations()->updateOrCreate([
'locale' => $locale
], [
'title' => $request->input('title.' . $locale),
'post' => $request->input('post.' . $locale),
]);
}
 
return redirect()->route('posts.index');
}
 
public function destroy(Post $post): RedirectResponse
{
$post->delete();
 
return redirect()->route('posts.index');
}
}

Lastly, we can look into our Post views and see how we can display the translated model to the user:

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>
{{-- We don't need to load anything as we already have pre-loaded the default translation --}}
<td>{{ $post->title }}</td>
{{-- We don't need to load anything as we already have pre-loaded the default translation --}}
<td>{{ Str::of($post->post)->limit() }}</td>
<td>{{ $post->publish_date ?? 'Unpublished' }}</td>
<td>
{{-- ... --}}
</td>
</tr>
@endforeach
</tbody>
</table>

As you can see, in our index we don't have to work with translation-specific things. We are already doing that in our Post Model with the defaultTranslation relationship and the title and post attributes.

Typically you would load the translations in the Controller and pass them to the view, but in this case, we are doing it in the Model. And we made sure that we are only loading the translations when we need them.

Next up is our create view, which is a bit more complicated:

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)
<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>

Take a close look at the @foreach(config('app.supportedLocales') as $locale) loop. We are looping through all the available locales and creating a field set for each one of them. That way we can display fields for each locale.

Another thing to look at is our name attributes. We are using the arrays for title and post to make sure that we can send all the translations in one request.

Next up is our edit view, which is very similar to our create view:

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)
<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->translations->where('locale', $locale)->first()?->title) }}">
@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->translations->where('locale', $locale)->first()?->post) }}</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>

Here you will see the same @foreach loop as in our create view. We are also using the old helper to prefill the form with the values that were submitted. If there are no values submitted, we are using the values from the database based on the translations relationship that we loaded in our PostController edit method.


Repository: https://github.com/LaravelDaily/laravel11-localization-course/tree/lesson/translating-content