The situation
More and more of your customers would rather read about you in their own language. Translating the site by hand means copying every page, post and FAQ into the new language, then the header, footer and cookie banner, one at a time, and keeping up as the originals change. One command does all of it: it adds the language to your site, copies everything into it, translates the words and leaves each one as a draft with a preview link. Your links, images and business name stay as they are. You read the drafts, publish them, and put the language live when you’re happy.What you’ll build
An Artisan command,php artisan site:translate fr "Français", in your own Laravel app. For each
page, blog post and FAQ it copies the document into the new language with the Fetchski Attract SDK,
then hands its words to a Laravel AI SDK agent, which answers them translated. Your code checks every
answer: a translation that loses a text, leaves a sentence in the main language, changes an HTML
tag or a link, or translates a name from your glossary goes back to the agent with the problems named. Only a translation that passes is
written, as a draft. Your header, footer and cookie banner get the same treatment: the site names
which of its settings hold those words, the agent translates just those, and your code points each
of their links at the page in the new language. Your code, not the agent, chooses what to
translate, sends it for review and makes the preview links. Nothing goes live until a person
publishes it.
Only what changed
Links, images and names kept
{year} the site fills in, or hands a sentence back untranslated.Drafts, never live
--publish.Spend counted exactly
- A Fetchski Attract site you can sign in to as someone who may manage API keys and languages.
- PHP 8.5 and Composer, with the Laravel installer (
composer global require laravel/installer). This page was built with Laravel 13.42, the Laravel AI SDK 1.4 and Pest. - An OpenRouter account with credit and an API key (openrouter.ai/settings/keys).
The agent runs on
~anthropic/claude-sonnet-latest, OpenRouter’s name for the newest Claude Sonnet, so it moves to each new Sonnet by itself. When you make the key, give it a credit limit (for example $20), so nothing can spend more. - About 30 minutes to set it up.
Make the API key
The app gets its own key, with only what the translation needs. It can add a language and write drafts, but it can’t publish a page, a post or an FAQ.Open API Keys
https://acme.example/cms-login. Press ⌘K
(Ctrl+K on Windows and Linux), type API Keys and press Enter.
Click Create API Key.Name it
Translate the site.Pick the Site translation pack
content:pages:publish (and its blog and FAQ twins)
the key can’t take a translation live, and without global:publish it can’t take the new
header and footer live either. global:write could change any of your site’s settings: the
command writes only the words and links each language keeps its own copy of.Check Granted before you create the key: it should list exactly the 13 scopes above.
Your site’s packs can be edited, so if it shows any other scope (a publish or delete one, say),
take it out with Edit scopes, or have someone who manages API keys choose Restore default
packs in Scope Packs.No Site translation pack in the list? Someone who manages API keys restores it: ⌘K, Scope
Packs, Restore default packs. Or choose Pick scopes one by one and tick the thirteen
scopes above.Pick the signing mode
Create it and copy both values
fsk_acme_…) and
the Signing secret (fss_…). They are shown once: keep them for steps 2 and 3 below.Step by step
Create the app
Build something amazing!.Install the SDKs
composer.json). The second
saves your key in the app’s auth.json. The third installs the SDK, and the API client it uses, from
your site. Put your site’s address in the first two lines (set your site in the bar under this page’s
title and the example shows it), and paste your whole key in place of fsk_acme_….fetchski/attract-cms-laravel calls your site, and laravel/ai runs the agent, with
OpenRouter as one of its providers and a fake for the tests.You should see: Using version … for fetchski/attract-cms-laravel and Using version … for laravel/ai.Point the app at your site
config/fetchski-attract.php and writes the four values to .env, then
calls the site to check it’s reachable, the key is accepted and the clocks agree.You should see: Config, Reachable and Clock read ok, and INFO The site acme is ready. The Key accepted row reads warn, the key works but may not list pages: that’s
expected, the key reads pages through their drafts.Add the model's environment variables
.env and add these lines at the end, with your OpenRouter key:openrouter provider reads OPENROUTER_API_KEY by itself, so its
config needs no publishing. The tests fake the model, so they never need the key.Gather the translator's options
glossary:Make the agent
schema() gives, in one step, with no
tools. One step per call means the raw OpenRouter answer the SDK keeps holds the call’s whole
cost (usage.cost), so the run’s spend is exact. #[Provider(Lab::OpenRouter)] sends it to
OpenRouter, and model() reads the model from the config.You should see: INFO Agent [app/Ai/Agents/Translator.php] created successfully.Reach any document on the site
app/Services/SiteDocuments.php:request() calls those routes by name, with
the key, its signature, retries and the SDK’s exceptions, as every SDK method does.Find the words, and check a translation
app/Support/Wording.php:of() keeps titles, headings, text, rich text and button labels, keyed as the site’s
PATCH writes them back, so a write changes the words and nothing else. For the site settings
it takes only the fields the site names with getGlobalTranslatedFields(): every other setting
is shared by all your languages. linksTo() points each header and footer link to one of your
pages at that page in the new language; a link to another site stays as it is. problems() is
the check every answer must pass before anything is written.Describe the run's outcome
app/Support/TranslatedDocument.php:app/Support/TranslationReport.php:Write the translator
app/Services/SiteTranslator.php:What needs translating
Copy
Translate
attempts. Each call may take
up to timeout_seconds (ten minutes: room for a long page on a slow model, so it only ends
a call that hangs), and a call that fails uses up an attempt rather than stopping the run.
A translation that passes is written, sent for review and, for pages and posts, shared for
its preview link.Add the command
SiteTranslator into handle(). Any document left untranslated
returns FAILURE (exit code 1), so a script around it can tell.You should see: php artisan site:translate --help prints the description, <code>,
<name> and --publish.Format the code
Run it
Translate
Read the drafts
/about-us becomes /fr/about-us); links to other sites stay as they are.Publish them
Submitted Drafts and press Enter. Open each
translation, change anything you like, and click Publish. Not happy with one? Dismiss it
there: it stays an unpublished draft.Put the language live
Keep it current
If something goes wrong
… isn't a language code
… isn't a language code
fr, or a language and a country, such as pt-br. Put the name in
quotes when it has a space: "Português (Brasil)".ERROR Site … answered PATCH /api/v1/site-languages/… with 400 invalid
ERROR Site … answered PATCH /api/v1/site-languages/… with 400 invalid
“en” is the main language’s code: that code is your main language’s. Give the new language its own.“/api” is already one of the site’s own addresses: some codes are taken by your site’s own addresses. Choose a different code.
An error naming a scope
An error naming a scope
content:…:write too), then run the command again.HTTP request returned status code 401: … User not found.
HTTP request returned status code 401: … User not found.
OPENROUTER_API_KEY. Copy the key again from
openrouter.ai/settings/keys; it starts with sk-or-v1-.
The documents copied before the error are translated on the next run.Not saved: … in the table
Not saved: … in the table
attempts calls, and the words after Not saved:
are the problems left. The draft is the untouched copy. Run the command again to retry just that
one; if a name keeps changing, add it to glossary in config/site-translate.php.Not translated: the run reached $10
Not translated: the run reached $10
max_cost_per_run_usd in config/site-translate.php.Not translated: the model didn't answer (…)
Not translated: the model didn't answer (…)
timeout_seconds in config/site-translate.php.Test it
The tests fake both sides.FakeSite keeps the site’s documents in memory and answers the routes the
translator calls, on top of Attract::fake(), which answers every SDK method and records it.
Translator::fake() answers for the model: so the real translator runs, with the real checks, and
nothing is sent or spent.
Remove the starter tests
The fake site
Attract::fake() takes a closure for request, called with each call’s method, path,
query and body, so one small router stands in for the site’s routes. A conflict answers with
Attract::siteError(409), as the site does when a copy is already there.The wording's tests
The translator's tests
[fr] in front of the words, so a test can see what was
written, and gives each answer a raw OpenRouter body with its cost, as a real answer has.The command's tests
Run them
Turn it off
Revoke the site key
API Keys, open Translate the site, and switch Enabled off (or
delete it). The app’s next call is refused.Revoke the OpenRouter key
.env.Take the language down
Languages, set its Public to No (visitors stop seeing it) or remove it. Its
translations are kept: add the same code again and they come back.Nothing runs on its own
Related
Your site in more than one language
Update the site's languages
Testing in Laravel
Attract::fake() and the assertions