mtubis / html-translator-filament
AI translation for Filament v5: preserve HTML markup, review translations, and flag them when the source changes.
Package info
github.com/mtubis/html-translator-filament
pkg:composer/mtubis/html-translator-filament
Requires
- php: ^8.4
- filament/filament: ^5.0
- mtubis/html-translator-laravel: ^0.2
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pint: ^1.32
- orchestra/testbench: ^11.3
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-phpunit: ^2.1
- phpstan/phpstan-strict-rules: ^2.1
- phpunit/phpunit: ^13.4.4
- rector/rector: ^2.7
- spatie/laravel-translatable: ^6.15
Suggests
- spatie/laravel-translatable: The first supported storage for translations (^6).
Provides
None
Conflicts
None
Replaces
None
README
A Filament v5 plugin for markup-safe AI translation of spatie/laravel-translatable models. It is built on
mtubis/html-translator-laravel and adds record and field
actions, a review workflow (machine → reviewed) and stale-translation detection.
- Editors: Translating in the panel.
- Developers: Requirements, Installation, Choosing a provider and a model, Queue, Command line.
Status: 0.2. The public API may still change in 0.x minor versions; the CHANGELOG says how. Plans: docs/ARCHITECTURE.md.
Translating in the panel (for editors)
This section is for the people who write and translate content in a Filament panel where the plugin is installed. Developers: the setup starts at Requirements.
Labels below are the plugin's English ones. The panel shows them in its own language when the plugin has it (interface languages), and an application may rename them.
The idea
- Content is written in one source language. The plugin translates it into the target languages with an AI model.
- An AI translation is a draft. Someone who knows the language reads it, corrects it if needed and marks it reviewed. The application decides what visitors see; often only reviewed translations.
- The plugin remembers, for every text and language, whether it is translated, a draft or reviewed, and notices when the source text changes later.
Translation states
The Translation column of a table shows the state of each target language, e.g. EN · Machine. It sums up the
whole record (and its related items, if the application counts them): the least advanced state wins. When a source
text changed after it was translated, a red Stale badge appears next to it, e.g. EN · Reviewed EN · Stale;
hover it to see why.
| Badge | Meaning | What to do |
|---|---|---|
| Not translated (grey) | At least one text has no translation in this language. | Generate a draft, or write the translation yourself. |
| Machine (yellow) | Translated by AI, not reviewed yet. | Read it, correct it, mark it as reviewed. |
| Reviewed (green) | Accepted by a person. | Nothing. |
| Stale (red, next to one of the above) | The source text changed after it was translated, so the translation may be out of date. | Update the translation (see When the source text changes). |
Translate one record
- Open the record for editing and switch the form to the target language (the language switcher of the edit page). The translation buttons are hidden while you edit the source language.
- Click Generate draft translation at the top of the page.
- Choose the Target language and What to translate:
- Only what is not translated yet (default): fills the gaps, keeps every existing translation.
- Also replace machine translations: translates the unreviewed drafts again too, e.g. after the source changed or with a better model.
- Also replace reviewed translations: translates everything again, including texts a person has already accepted. You have to tick I understand that reviewed translations will be replaced.
- Confirm. What happens next depends on the application:
- Right away: after a moment the form shows the drafts and a notification says how many texts were translated, skipped, incomplete or failed.
- In the background (queued): a notification says Translation started. When the draft is ready, Draft translation ready (or finished with problems) appears under the bell icon of the panel (it can take up to a minute). Reload the page to see the draft. (From version 0.3 the page will update by itself.)
If a text could not be translated completely, the notification lists the problems and the old value stays in place. Try again later, or translate that text yourself.
Translate one field
Next to a field (in the target language) there is a Translate button. It fills only that field with a draft from the source text and saves nothing. Check the text and click Save: the field becomes a Machine draft.
Use it to redo one text without touching the others, or to update a single stale text.
Translate many records
In the table, select the records (the checkbox in the header selects the whole page), open the bulk actions and choose Generate draft translations. The form is the same as for one record. In the background mode you get one notification when all records are done.
Review a translation
- Open the record in the target language and read the drafts. Correct what needs correcting and Save. Saving does not review: the state stays Machine.
- Click Mark as reviewed at the top of the page (or in the table row, if the application shows it there) and confirm.
This marks every draft of the record in that language as reviewed, and also translations you typed yourself, with a note of who reviewed it and when. Empty texts stay Not translated.
Write a translation yourself
You do not have to use AI. Switch to the target language, type the translation, Save, then Mark as reviewed.
When the source text changes
When someone edits the source text, its translations get the Stale badge next to their state. A reviewed translation stays visible as it is (it is never removed or downgraded automatically), but it may no longer match the source.
Update it in one of two ways, in the target language:
- Correct it yourself: compare it with the new source text, change what needs changing, Save.
- Start from a new draft: click Translate next to the changed field, or run Generate draft translation with Also replace machine translations (or Also replace reviewed translations when the old translation was reviewed). Check and correct the draft, Save.
Then click Mark as reviewed. The confirmation says how many of the translations are stale: confirm only when you have checked them against the current source text. Afterwards they are Reviewed and no longer Stale.
From the command line
Developers or administrators can work on many records at once (see Command line):
html-translator-filament:translate makes drafts, exactly as from the panel, and after the drafts were checked,
html-translator-filament:mark-reviewed marks them as reviewed on behalf of a named reviewer. Stale translations are
not marked unless the reviewer says they were checked against the current source text.
Requirements
PHP 8.4+, Laravel 13, Filament 5, mtubis/html-translator-laravel ^0.2 (installed with the plugin). The translations
are made by an AI model, so you need an account and an API key for a provider (OpenAI, Anthropic, Gemini) or a local
model server (Ollama, vLLM, LM Studio). See Choosing a provider and a model.
Installation
composer require mtubis/html-translator-filament
Register the plugin on a panel:
use HtmlTranslator\Filament\HtmlTranslatorPlugin; $panel->plugin( HtmlTranslatorPlugin::make() ->sourceLocale('es') // defaults to the first locale ->locales(['es', 'en']) // source + targets ->profile('site') // an entry of config/html-translator.php; null = the package default ->provider(null) // likewise ->queue(), // translate in queued jobs, see Queue );
Choosing a provider and a model
The plugin does not talk to AI providers itself: it uses the providers configured for
mtubis/html-translator-laravel. Nothing translates until one
of them has a key and a model.
-
Publish the configuration file,
config/html-translator.php:php artisan vendor:publish --tag=html-translator-config
-
Pick a provider and set its key and model in
.env.HTML_TRANSLATOR_PROVIDERnames the provider entry used by default. Without it the default isopenai, so the actions fail untilOPENAI_API_KEYandHTML_TRANSLATOR_OPENAI_MODELare set.# Anthropic HTML_TRANSLATOR_PROVIDER=anthropic ANTHROPIC_API_KEY=sk-ant-... HTML_TRANSLATOR_ANTHROPIC_MODEL=claude-sonnet-5-5 # required: there is no default model
Provider HTML_TRANSLATOR_PROVIDERKey Model (required) OpenAI openaiOPENAI_API_KEYHTML_TRANSLATOR_OPENAI_MODELAnthropic anthropicANTHROPIC_API_KEYHTML_TRANSLATOR_ANTHROPIC_MODELGoogle Gemini geminiGEMINI_API_KEYHTML_TRANSLATOR_GEMINI_MODELOllama (local) ollamanone ( OLLAMA_BASE_URL, defaulthttp://localhost:11434/v1)HTML_TRANSLATOR_OLLAMA_MODELPseudo-localization pseudonone none The model value is the provider's own model ID, exactly as in its API documentation. Other servers (vLLM, LM Studio, any OpenAI-compatible API) and several entries for one provider (a cheap and a strong model) are set up in
config/html-translator.php; see the providers section of html-translator-laravel. -
Check the configuration. It lists every problem at once and never prints a key;
--pingalso sends one short test translation per provider:php artisan html-translator:check --ping
-
If configuration is cached (
php artisan config:cache, usual in production), rebuild the cache, and restart your queue workers (php artisan queue:restart): a runningqueue:workkeeps the configuration it started with.
Without an API key (local development, demos, tests) use HTML_TRANSLATOR_PROVIDER=pseudo. It needs no network
and no key. It does not really translate: it returns the source text with accented letters (Servicios →
[!! Šéŕṽíçíöš !!]), so you can try every step of the workflow and see that the markup survives.
To use an entry other than the default only in the panel, name it on the plugin: ->provider('anthropic').
Profiles (tone, glossary, terms that must not be translated) are configured the same way, in
config/html-translator.php, and picked with ->profile('name').
Translation states
The plugin tracks one state per record, attribute and target locale: missing, machine or reviewed. A
translation is stale when its source text changed after it was made. Being stale is derived, never stored, so a
reviewed translation is never downgraded.
-
Publish and run the migration. An existing
translation_statestable with the same schema is left alone.php artisan vendor:publish --tag=html-translator-filament-migrations php artisan migrate
-
Let your translatable models track their states:
use HtmlTranslator\Filament\Contracts\TracksTranslations; use HtmlTranslator\Filament\Models\Concerns\HasTranslationStates; use Spatie\Translatable\HasTranslations; final class Post extends Model implements TracksTranslations { use HasTranslations; use HasTranslationStates; public array $translatable = ['title', 'body']; }
Saving a source text creates
missingstates for every target locale of the plugin. Deleting the record removes its states. Records removed by a database cascade fire no model events: callTranslationStateStore::deleteFor($morphType, $ids)for them. -
Recommended:
Relation::enforceMorphMap([...]), so the table stores short, stable types instead of class names.
HtmlTranslator\Filament\Contracts\TranslationStateStore is the API for reading and writing states. Bind your own
implementation to keep them elsewhere.
Reviewing accepts a translation for the current source text: markReviewed() and markReviewedAll() store the
current source hash, so a stale translation is no longer stale once reviewed. reviewable($record, $locale, includeStale: false) lists what markReviewedAll() would mark, leaving out stale translations; pass the same flag to
markReviewedAll() to review only translations whose source has not changed.
Translating a record
TranslateRecord translates the source-locale values of a record, and of the relations you name, into one target
locale. It writes the results and marks their states machine:
use HtmlTranslator\Filament\Enums\OverwritePolicy; use HtmlTranslator\Filament\Translation\TranslateRecord; $report = app(TranslateRecord::class)($post, 'en', OverwritePolicy::OnlyMissing, relations: ['comments']); $report->translated(); $report->skipped(); $report->incomplete(); $report->failed(); $report->stats(); // units, batches, input/output tokens, cache hits $report->outcomes(); // per attribute: status, reason, issues
- Overwrite policies:
OnlyMissing(default) translates only what has no translation yet.OverwriteMachinealso replaces machine translations.Forcealso replaces reviewed ones; choose it explicitly. - HTML attributes must be declared on the model:
protected array $htmlTranslatable = ['body'];. Every other translatable attribute is sent as plain text (escaped), so markup in an undeclared attribute would come back as text. - Incomplete or failed attributes keep their previous value and are reported. Model calls happen before the database transaction.
- The profile and provider come from the plugin (
->profile(),->provider());nulluses the html-translator-laravel defaults.
Filament actions and column
use HtmlTranslator\Filament\Actions\MarkTranslationReviewedAction; use HtmlTranslator\Filament\Actions\TranslateBulkAction; use HtmlTranslator\Filament\Actions\TranslateRecordAction; use HtmlTranslator\Filament\Tables\Columns\TranslationStatusColumn; // Edit page header protected function getHeaderActions(): array { return [ TranslateRecordAction::make()->relations(['comments']), MarkTranslationReviewedAction::make()->relations(['comments']), ]; } // Table $table ->modifyQueryUsing(fn ($query) => $query->with('translationStates')) // one query for every row's badges ->columns([TranslationStatusColumn::make()]) ->recordActions([MarkTranslationReviewedAction::make()]) ->toolbarActions([TranslateBulkAction::make()]);
TranslateRecordActionasks for the target locale and the overwrite policy.Forceneeds an explicit confirmation. It reports counts, token usage and issues in a notification, and refreshes the form when the page edits the translated locale.MarkTranslationReviewedActionmarks the edited locale's machine drafts and translations written by hand as reviewed, with the current user as reviewer. Saving an edit does not review it: reviewing is the explicit step.TranslationStatusColumnshows one badge per target locale: not translated, machine, stale or reviewed.- Active locale: pages and relation managers with a public
activeLocaleproperty (like those oflara-zeus/spatie-translatable) tell the actions which locale is being edited. The actions are hidden while the source locale is edited. Without the property, the first target locale is used. - Notifications contain only counts, usage and the translator's issue messages, never prompt text, translations or keys. Labels follow the application locale, see Interface languages.
Field action
Re-translate a single field without touching the others:
RichEditor::make('body')->translateWithAi(); // macro TextInput::make('title')->hintAction(TranslateFieldAction::make()); // the same, statically typed
- The action fills the field in the edited locale and saves nothing. When the editor saves the form, that attribute
is marked
machine, unless its source text changed in the meantime. - It is hidden while the source locale is edited. Incomplete or failed translations leave the field unchanged and show a warning.
- The field name must be the model attribute. HTML vs plain text follows the model's
$htmlTranslatable.
Queue
With a real provider a record takes seconds, so translate in the background:
HtmlTranslatorPlugin::make() ->locales(['es', 'en']) ->queue(connection: 'database', queueName: 'translations') // both optional ->rateLimit(30); // jobs per minute, optional $panel->databaseNotifications(); // the editor is told when a job (or a bulk batch) is done
Without ->queue() the record and bulk actions translate during the request, and the editor waits for the result.
The field action (translateWithAi()) always translates during the request, because it fills the form.
Running the worker
With ->queue() the translate action only dispatches a job: nothing is translated until a queue worker runs it.
The worker must listen on two queues:
- the plugin's queue: the
queueNameyou passed, or the connection's default queue (default) when you passed none; - the connection's default queue, usually
default: Filament's database notifications ("Draft translation ready", "The translation could not be finished") are queued jobs too, and Filament puts them there.
# The example above (queueName: 'translations'): php artisan queue:work --queue=translations,default # Without a queueName both are the default queue: php artisan queue:work
A worker started as plain php artisan queue:work (or queue:listen) listens only on default, so jobs on
translations wait in the queue forever and no error is shown. Pass the same connection the plugin uses as the first
argument when it is not your QUEUE_CONNECTION (php artisan queue:work redis --queue=translations,default).
- Development:
php artisan queue:listen --queue=translations,defaultreloads the code and configuration for every job. Run the worker in the same environment as the application (e.g../vendor/bin/sail artisan …with Sail), so it uses the same database, PHP extensions and.env. - Production: keep
queue:workrunning under a process manager (Supervisor, systemd) and runphp artisan queue:restartafter every deployment and every.envchange. - Tables: the
databaseconnection needs thejobstable; bulk runs use job batches (job_batches), and failed jobs go tofailed_jobs. New Laravel applications have all three; otherwise runphp artisan make:queue-table,php artisan make:queue-batches-table,php artisan make:queue-failed-tableandphp artisan migrate.
Seeing the result
- After the click the editor sees "Translation started". The form does not change yet.
- The worker translates the record (seconds to a minute, depending on the model and the length of the text), saves
the translation and marks it
machine. - A database notification ("Draft translation ready") appears in the panel's bell. Filament checks for new notifications every 30 seconds
(
$panel->databaseNotificationsPolling('10s')to change it). - Reload the edit page and switch to the target locale to see the draft.
Nothing happens after clicking?
| Symptom | Cause | Fix |
|---|---|---|
No translation, no notification, rows waiting in the jobs table |
No worker on the plugin's queue | php artisan queue:work --queue=translations,default |
| Translation saved, but no notification | The worker does not listen on default |
Add default to --queue |
| "The translation could not be finished" notification (after 3 attempts, about a minute) | Provider not configured, or it rejected the request (key, model, quota) | php artisan html-translator:check --ping; details in the log and php artisan queue:failed |
| The text came back with accented letters | The pseudo provider is the default |
Set a real provider, see Choosing a provider and a model |
| A new key or model is ignored | The worker or the config cache holds the old configuration | php artisan config:clear (or config:cache), then php artisan queue:restart |
php artisan queue:monitor database:translations shows how many jobs are waiting.
Behaviour
- One record and locale is never translated twice at the same time (
WithoutOverlapping). Failed jobs notify the editor with a generic message; exception details stay in the logs. - Relation managers that show translations should
use HtmlTranslator\Filament\Concerns\RefreshesAfterTranslation;, so their badges refresh after a synchronous translate action on the page.
Command line
Translate the records of one or more models into the plugin's target locales:
php artisan html-translator-filament:translate "App\Models\Post" --dry-run # what would be translated; no API calls php artisan html-translator-filament:translate "App\Models\Post" # translate now, in this process php artisan html-translator-filament:translate post page --queue # morph aliases; a batch of queued jobs
| Option | Meaning |
|---|---|
--locale=en |
Target locale; repeatable. Default: every target locale of the plugin. |
--policy= |
only-missing (default), overwrite-machine or force, as in the panel. |
--relations=comments |
Relations translated together with each record; repeatable. |
--id=12 |
Only these records; repeatable. |
--dry-run |
Lists what would be translated (-v: per record and attribute). Calls no provider, writes nothing, needs no API key. |
--queue |
Dispatches one job batch on the plugin's connection and queue and prints the worker command. Nothing is translated until a worker runs it; no panel notifications are sent. |
--provider=, --profile= |
Another entry of config/html-translator.php for this run; synchronous runs only. |
--fail-on-incomplete |
Exit code 1 also when a translation is incomplete. |
--force |
Required with --policy=force when there is no one to answer the confirmation (cron, CI, --no-interaction). |
- Results are machine drafts (state Machine), like translations made in the panel. With
only-missinga record whose texts are all translated costs nothing: no provider call is made for it. - Output: a progress bar per model and locale, a warning for every incomplete or failed attribute (the
translator's messages with
-v), and totals: translated, skipped, incomplete, failed, units, tokens and cache hits. Source texts, translations and API keys are never printed. - Exit code 1 when an attribute failed, an argument is invalid, or the
forceconfirmation is refused; 0 otherwise. - Settings: locales, provider, profile and queue come from the plugin registered on your panel. With several panels, the settings of the last registered one apply.
Translate new and changed content every night (in routes/console.php):
use Illuminate\Support\Facades\Schedule; Schedule::command('html-translator-filament:translate', ['post', '--no-interaction'])->dailyAt('02:00');
Marking translations as reviewed
The typical flow: translate, check the drafts (in the panel, or with whoever reviews them), then mark them reviewed:
php artisan html-translator-filament:mark-reviewed post --reviewer=editor@example.com --dry-run # what would be marked php artisan html-translator-filament:mark-reviewed post --reviewer=editor@example.com # asks to confirm
| Option | Meaning |
|---|---|
--reviewer= |
Required. The user who reviewed the translations: user ID or e-mail address (default auth guard). Stored as the reviewer, like Mark as reviewed in the panel. |
--locale=, --relations=, --id= |
As for translate. |
--include-stale |
Also mark stale translations (their source text changed after translation). Use it only after checking them against the current source; they are then accepted for it and no longer stale. |
--dry-run |
Lists the translations that would be marked and the stale ones left out; changes nothing. |
--force |
Mark without the confirmation question (required with --no-interaction). |
- What is marked: the same as Mark as reviewed in the panel: machine drafts and translations written by hand. Empty translations and reviewed translations whose source has not changed are left alone.
- Stale translations are left out by default and listed as stale, not marked, so "reviewed" never silently covers a text that was checked against an older source.
- Each record (with its relations) is marked in one database transaction. Exit code 1 for an unknown model, locale
or reviewer, a missing
--reviewer, or a refused confirmation.
Interface languages
The plugin's buttons, forms, states and notifications follow the application locale (app()->getLocale()), like
Filament's own texts. Shipped languages:
| Locale | Language | Review |
|---|---|---|
en |
English | source |
de |
German | fluent speaker |
pl |
Polish | native speaker |
es |
Spanish | non-native speaker; native input welcome |
fr |
French | non-native speaker; native input welcome |
uk |
Ukrainian | non-native speaker; native input welcome |
zh_CN |
Chinese (Mandarin, Simplified) | non-native speaker; native input welcome |
A locale without a file falls back to English (Laravel's fallback_locale, when it is en).
Corrections are welcome, especially from native speakers: open an issue or a pull request with the text and your suggestion. A short note on why (register, terminology used elsewhere in Filament) helps the next reviewer.
Change a text or add a language in your application, without touching the package:
php artisan lang:publish # once, if the application has no lang/ directory yet
mkdir -p lang/vendor/html-translator-filament/fr
cp vendor/mtubis/html-translator-filament/lang/en/messages.php lang/vendor/html-translator-filament/fr/messages.php
Then translate the values (keep the keys and the :placeholders). A file there may also contain only the keys you
want to change. Texts with a count have plural forms separated by |: one|other in most languages, three forms
in Polish (1 | 2–4 | 5+).
New languages for the package itself are welcome as pull requests; a test checks that every language file has the English keys and placeholders.
Development
See CONTRIBUTING.md. composer check runs Pint, Rector, PHPStan and PHPUnit. composer serve
starts the workbench panel.
License
MIT, see LICENSE.