CMS integration
A common screen in a CMS: per model and per locale, which items still miss a translation, with a button to translate them. This page builds that screen with Livewire.
What you need first
- The package does not require Livewire and ships no screens. Install Livewire yourself with
composer require livewire/livewire. The component below works with Livewire 3 and Livewire 4. - A Livewire layout, because the component is shown as a full page. See Pages in the Livewire docs.
- A running queue worker, because the buttons dispatch jobs.
- Models that use the trait, as in the quick start. The example uses
App\Models\PageandApp\Models\Post, each with atitleand aslugcolumn.
The job
Every field is one HTTP request to Google, so the button dispatches a job instead of translating inside the request.
app/Jobs/TranslateModel.php:
<?php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
class TranslateModel implements ShouldQueue
{
use Queueable;
public function __construct(public Model $model, public string $locale) {}
public function handle(): void
{
$translation = $this->model->createTranslation($this->locale, [
'slug' => Str::slug($this->model->title).'-'.$this->locale,
]);
if ($translation === null) {
// No API key, nothing to translate, or every field failed. The reason is in the log.
$this->fail('Translation to '.$this->locale.' failed for '.$this->model::class.' #'.$this->model->getKey());
}
}
}
The job creates the translated row. When createTranslation() returns null, fail() marks the job as failed, so it shows up in php artisan queue:failed instead of disappearing.
The component
app/Livewire/Translations/TranslationsOverview.php:
<?php
namespace App\Livewire\Translations;
use App\Jobs\TranslateModel;
use App\Models\Page;
use App\Models\Post;
use Darvis\LaravelGoogleTranslate\GoogleTranslateService;
use Livewire\Attributes\Computed;
use Livewire\Component;
class TranslationsOverview extends Component
{
/** @var array<string, class-string> */
protected array $models = [
'Pages' => Page::class,
'Posts' => Post::class,
];
#[Computed]
public function available(): bool
{
return app(GoogleTranslateService::class)->isAvailable();
}
/**
* @return array<string, array<string, \Illuminate\Support\Collection>>
*/
#[Computed]
public function missing(): array
{
$missing = [];
foreach ($this->models as $label => $model) {
foreach ($this->locales() as $locale) {
$missing[$label][$locale] = $model::getMissingTranslations($locale);
}
}
return $missing;
}
public function translate(string $label, int $id, string $locale): void
{
$this->guard($label, $locale);
$model = $this->models[$label]::sourceItems()->findOrFail($id);
TranslateModel::dispatch($model, $locale);
}
public function translateAll(string $label, string $locale): void
{
$this->guard($label, $locale);
foreach ($this->missing[$label][$locale] as $model) {
TranslateModel::dispatch($model, $locale);
}
}
public function render()
{
return view('livewire.translations.translations-overview');
}
/**
* @return array<int, string>
*/
protected function locales(): array
{
return app(GoogleTranslateService::class)->getTargetLocales();
}
/**
* The label and the locale come from the browser, so check them against the lists.
*/
protected function guard(string $label, string $locale): void
{
abort_unless(isset($this->models[$label]) && in_array($locale, $this->locales(), true), 404);
}
}
missing() asks every model for its source rows without a translation, per locale from GOOGLE_TRANSLATE_TARGET_LOCALES. The arguments of translate() and translateAll() come from the browser, so guard() only accepts a label and a locale from your own lists, and sourceItems() makes sure a translation row is never translated again.
getMissingTranslations() runs one query per source row. For a large table, paginate the source rows yourself and call hasTranslation() per row.
The view
resources/views/livewire/translations/translations-overview.blade.php:
<div>
@unless ($this->available)
<p class="text-red-600">GOOGLE_TRANSLATE_API_KEY is not set, so nothing can be translated.</p>
@endunless
@foreach ($this->missing as $label => $locales)
<h2>{{ $label }}</h2>
@foreach ($locales as $locale => $items)
<h3>
{{ strtoupper($locale) }}: {{ $items->count() }} missing
@if ($items->isNotEmpty())
<button wire:click="translateAll('{{ $label }}', '{{ $locale }}')" @disabled(! $this->available)>
Translate all
</button>
@endif
</h3>
<ul>
@foreach ($items as $item)
<li wire:key="{{ $label }}-{{ $locale }}-{{ $item->id }}">
#{{ $item->id }} {{ $item->title }}
<button wire:click="translate('{{ $label }}', {{ $item->id }}, '{{ $locale }}')" @disabled(! $this->available)>
Translate
</button>
</li>
@endforeach
</ul>
@endforeach
@endforeach
</div>
The list is computed when the component renders. A row disappears from it once the queue worker has created the translation and the page is rendered again.
The route
routes/web.php:
use App\Livewire\Translations\TranslationsOverview;
use Illuminate\Support\Facades\Route;
Route::get('/cms/translations', TranslationsOverview::class)->middleware(['auth']);
This form works in Livewire 3 and in Livewire 4. Livewire 4 recommends its own Route::livewire('/cms/translations', TranslationsOverview::class), which does not exist in Livewire 3.
Put the route behind the authorisation of your CMS. auth (the middleware that requires a logged in user) is the minimum; every click sends text to Google on your account.
Translate on save
To translate new content without a click, dispatch the job from an observer or from the place where the editor publishes:
use App\Jobs\TranslateModel;
use Darvis\LaravelGoogleTranslate\GoogleTranslateService;
if ($page->pid === null) {
foreach (app(GoogleTranslateService::class)->getTargetLocales() as $locale) {
TranslateModel::dispatch($page, $locale);
}
}
The check on pid limits this to source rows. Without it, a saved translation is sent to Google as well, to fill the locales that are still missing, and you get a translation of a translation.