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:
For this demo, we will create Posts
in multiple languages. The idea here will be:
config/app.php
as supportedLocales
)Post
model that will have all the details about the post, except for the title and contentPostTranslation
model that will have the title and content of the post in a specific languagehasOne
relation and attribute mutators in our Post
model)Post
and its translations in one goHere'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