Skip to main content

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

Run it again any time. A translation is redone only when its original changed since it was copied, so a second run on an unchanged site makes no model calls and costs nothing.

Links, images and names kept

The agent sees only the words. Every link, image, color and setting stays as it was, and your code refuses a translation that changes an HTML tag, a link, a name from your glossary or a {year} the site fills in, or hands a sentence back untranslated.

Drafts, never live

Every translation is a draft in Submitted Drafts. The new language stays private, so only people signed in to the CMS see it, until you run the command with --publish.

Spend counted exactly

Each model call is one step, so OpenRouter’s answer carries its whole cost. The run adds up its tokens and dollars, prints them, and starts no new document past $10.
Before you start, you need:
  • 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

Sign in to your site’s CMS at 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

Label: Translate the site.

Pick the Site translation pack

Under Scopes, keep Start from a pack and pick the Site translation pack. It ticks these thirteen scopes:Add nothing else. Without 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

Request signing: Token + signature on writes. The SDK signs each write for you with the key’s signing secret.

Create it and copy both values

Click Create key. Under Your API key is ready, copy the Secret token (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

Terminal
Why: a plain Laravel app with Pest for the tests. The command needs no front end, so no starter kit and no Node build; the app starts on SQLite, which this Recipe doesn’t use.You should see: an Application ready box ending Build something amazing!.

Install the SDKs

Your own site serves the SDK, and any API key from it lets you in. In a terminal, in your Laravel app’s folder, run:
Terminal
The first line tells Composer the SDK comes from your site (it’s saved in 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_….
Any API key from your site works, the same one your app uses included: its scopes and signing mode don’t matter for installing, only that it’s switched on and not past its expiry date. No key yet? Make one in the CMS under API Keys (Authentication).
auth.json holds a key to your site: never commit it. A Laravel app’s .gitignore already lists /auth.json; keep that line there (add it if yours doesn’t have it). In CI, write the same auth.json from a secret before installing.
401 Unauthorized? Your site refused the key in auth.json: it’s mistyped or cut short, switched off, or past its expiry date. Run the second line again with the right key.
Then add the Laravel AI SDK:
Terminal
Why: 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

Terminal
It asks for your site’s slug and address, then the Secret token and the Signing secret you copied.Why: it publishes 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

Open .env and add these lines at the end, with your OpenRouter key:
.env
Why: the Laravel AI SDK’s 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

Create the file, with your own names in glossary:
config/site-translate.php
Why: the limits cap every run, whatever the model does, and the glossary goes to the agent with every document. Add a name whenever a translation changes one.

Make the agent

Terminal
Then replace the file it made. It is the file pinned beside this page; here it is again in full:
app/Ai/Agents/Translator.php
Why: a structured agent answers JSON in the shape its 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

Create app/Services/SiteDocuments.php:
app/Services/SiteDocuments.php
Why: a page shows documents of many types (images, authors, categories, forms), and the site copies each into a language through the same routes. 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

Create app/Support/Wording.php:
app/Support/Wording.php
Why: 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

Create app/Support/TranslatedDocument.php:
app/Support/TranslatedDocument.php
And app/Support/TranslationReport.php:
app/Support/TranslationReport.php

Write the translator

Create app/Services/SiteTranslator.php:
app/Services/SiteTranslator.php
Why: your code does the whole run in three phases, and the agent only translates.

What needs translating

Every page (parents first), post and FAQ, and the site settings, compared with its copy: a translation is up to date when it was copied from the current original and written since.

Copy

What any of them shows first, by the same rule (so a copy a stopped run left is translated by the next run), then each one, over an older copy. A key missing a scope stops the run here, before anything is overwritten. The site settings’ links to your pages are then pointed at the copies.

Translate

One call per document, plus one per round of fixes, up to 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

Terminal
Replace the file it made:
app/Console/Commands/SiteTranslate.php
Why: Laravel injects the 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

Terminal
Why: Pint applies Laravel’s code style to every file you added.

Run it

Translate

Give the language’s code and its name as its speakers write it:
Terminal
You should see, after a few seconds per document (the cost and tokens are the model’s, so yours will read differently):
Terminal
Pages and blog posts get a preview link. FAQs and what the pages show have no page of their own: they are in Submitted Drafts.

Read the drafts

Open a Preview link: it is the translated page as visitors will see it, with no sign-in, even while the language is private. Send it only to the people reviewing it.Your header, footer and cookie banner are one draft, Header, footer and cookie banner in Submitted Drafts. Their links to your pages already point at the pages in the new language (/about-us becomes /fr/about-us); links to other sites stay as they are.

Publish them

Sign in to the CMS, press ⌘K, type 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

Terminal
It makes the language public, translates anything new or changed (as drafts, as always), and ends:
Terminal
Visitors now get a language switcher in your site’s header. A page you haven’t published in French isn’t there in French.

Keep it current

Run the same command after you change your site. Only what changed is translated again, and its new draft goes back to Submitted Drafts.

If something goes wrong

Use a lowercase code, such as fr, or a language and a country, such as pt-br. Put the name in quotes when it has a space: "Português (Brasil)".
The site refused the new language, and the end of the line says why:
  • “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.
Nothing was added or copied. Run the command again with another code.
The key lacks that scope: the run stopped before it overwrote anything. Add the scope to the key under API Keys (a page that shows a kind of document missing from the table above, such as a team member, needs that kind’s content:…:write too), then run the command again.
OpenRouter refused 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.
The agent’s answer didn’t pass the checks in 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.
The run’s budget is spent. Everything before it is saved: run the command again to go on, or raise max_cost_per_run_usd in config/site-translate.php.
Every attempt at that document failed: the words in brackets say how (a timeout, an outage, a rate limit). The other documents went on, and this one’s draft is the untouched copy. Run the command again to translate just that one. If it keeps timing out on a long page, raise timeout_seconds in config/site-translate.php.
Your site refused the key in auth.json: it’s mistyped or cut short, switched off, or past its Expires date. Run the composer config bearer… line again with the whole key.

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

Terminal

The fake site

tests/FakeSite.php
Why: 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

tests/Unit/WordingTest.php

The translator's tests

tests/Feature/SiteTranslatorTest.php
Why: the fake translator puts [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

tests/Feature/SiteTranslateCommandTest.php

Run them

Terminal
You should see:
Terminal

Turn it off

Revoke the site key

In the CMS, ⌘K → API Keys, open Translate the site, and switch Enabled off (or delete it). The app’s next call is refused.

Revoke the OpenRouter key

Delete it at openrouter.ai/settings/keys, then remove the lines you added to .env.

Take the language down

⌘K → 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

The command works only when you run it. Drafts it already made stay in the CMS until you publish or dismiss them.

Your site in more than one language

Languages, translating by hand, and what changes for visitors

Update the site's languages

The call that adds a language and makes it public

Testing in Laravel

Attract::fake() and the assertions

The same Recipe in AdonisJS

An OpenRouter agent with one checked save tool