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