sharpapi/nova-ai-translator
Add AI-powered translation to Laravel Nova. This package integrates SharpAPI to translate resource content and multilingual fields directly from Nova, making localization workflows faster and simpler for Laravel applications.
Prerequisites: Laravel Nova 4, PHP 8.1+, and a SharpAPI.com account (the action calls SharpAPI's AI translation endpoint, so you need an API token before anything works). If you plan to run the action on multiple selected records (bulk/index), require v1.1.2+ — earlier versions have a batch-abort bug (see Gotchas).
Install:
composer require sharpapi/nova-ai-translator:^1.1.2
php artisan vendor:publish --tag=nova-ai-translator-config
Configure — set the key in .env (check the published config/nova-ai-translator.php for the exact variable name; typically SHARPAPI_API_KEY) and clear config cache:
php artisan config:clear
First use case — translate one record's content field into a target locale:
src/Actions/ in the package to confirm the action class name and its public configuration API (small package, no docs guarantee — read the source first, it's a 10-minute read). Pay attention to two things while you're in there: which attributes handle() writes to, and the "already contain content" check it runs before translating.app/Nova/Resource.php:public function actions(Request $request): array
{
return [
(new \SharpApi\NovaAiTranslator\Actions\AiTranslate())
->onlyOnDetail()
->canRun(fn ($request, $model) => $request->user()->can('update', $model)),
];
}
$model->refresh() in tinker).Start with one field on one model in a staging environment. This is a small, low-adoption package — validate behavior against your schema before wiring it into production resources.
Know the skip semantics up front: before translating, the action checks whether a record already has every target-language field filled. If so, it marks that record finished without translating it, and the response reports how many records were skipped. The "already contain content" message you'll see in Nova is shown only when all selected records were skipped — if you see it alongside a successful run, that's the aggregate note, not a failure.
Detail-only is the safe default, but bulk now works. Since v1.1.2, selecting several records and running the action translates each eligible one independently: a record with all target fields already filled is skipped on its own and the remainder of the batch proceeds. Before that fix, one fully-translated record caused every subsequent record in the batch to be silently skipped — so if bulk translation is part of your workflow, pin ^1.1.2 and re-verify any batches you ran on older versions (they may have done far less work than the UI implied). Even with the fix, translation is an expensive, slow external call: prefer ->onlyOnDetail() for exploratory use, and use bulk deliberately, not casually.
Always queue it. Nova actions can implement Illuminate\Contracts\Queue\ShouldQueue. Because the action makes an HTTP round-trip to SharpAPI, a synchronous run will hit PHP/Nova timeouts on any meaningful text length — and a synchronous bulk run multiplies that risk by the number of selected records:
class TranslateContent extends AiTranslate implements ShouldQueue
{
public $queue = 'translations';
public $tries = 3;
public $timeout = 120;
}
Then run a worker and watch action_events in Nova for completion status. Note that per-record skips are reported in the action's response message; in a queued run nobody is staring at the modal, so log the skipped/translated counts yourself if you need an audit trail.
Subclass for your schema. Rather than configuring fields inline everywhere, create a thin app-level action per content type:
class TranslateArticle extends AiTranslate
{
public function fields(NovaRequest $request): array
{
return [
Select::make('Target Locale')
->options(['es' => 'Spanish', 'de' => 'German', 'pt-BR' => 'Portuguese (BR)']),
];
}
}
This keeps locale lists and field mappings in one place instead of duplicated across resources.
Pair with a translation storage convention. If you use spatie/laravel-translatable or nova-translatable, decide up front whether the action writes to the source columns (e.g. title) or to JSON translation keys (title->es). The action will do whatever the package's handle() does — check whether it calls $model->update([...]) or sets attributes; you may need to override handle() to write into a translations JSON column. The skip logic checks the fields the package knows about, so a custom handle() writing to JSON keys will fall outside it unless you replicate the guard.
Gate by locale state. Hide or disable the action when a translation already exists to prevent silent overwrites:
->canRun(fn ($request, $model) => blank($model->getTranslation('title', 'es')))
This complements — it doesn't replace — the built-in skip: the package only short-circuits records where all target fields are filled, so a partially translated record is still processed and its existing values can be overwritten.
Chain with review actions. Since output is AI-generated, follow the translate action with an approval/publish action, or set the model to draft status on translation. Treat the AI result as input to human review, not as final content.
Test with Http::fake(). In Pest/PHPUnit, fake the SharpAPI endpoint and assert the action updates the model, so your test suite doesn't burn API credits or require network access. Add a regression test mirroring the 1.1.2 fix: run the action over a collection where the first record is fully translated and assert the second is still processed.
^1.1.2, and audit any historical bulk runs: a "successful" action event may have translated nothing.->canRun() checks or override handle().SHARPAPI_API_KEY added to .env but not picked up ⇒ you forgot php artisan config:clear (or you're on a cached-config deploy). This is the #1 first-run failure._draft column first. There is no undo.en, es, pt-BR vs pt_BR, zh-Hans). Mismatches return a 422 or, worse, silently translate to the wrong language. Validate your Select options against SharpAPI's documented language list.Purifier, Str::of()->sanitize()) before persisting, or restrict translation to plain-text fields.array or json, the action may hand SharpAPI a stringified payload or a PHP array that fails validation. Check the cast before enabling the field — and note the "all fields filled" skip check evaluates those casts too, so a non-empty array may suppress translation you actually wanted.RateLimited/WithoutOverlapping middleware on your queued action.composer.json (^1.1.2), read src/ when behavior surprises you, and be prepared to fork — the action's handle() is the main extension point.Log::error() with the model ID and response body so you can debug without re-running paid requests.fields(), handle(), and the action's name/confirmation strings. If the package's handle() (or its skip logic) is too opinionated, call SharpAPI directly via Http::withToken(config('...'))->post(...) in your own action — the package is essentially a thin wrapper, so replicating it is cheap.How can I help you explore Laravel packages today?