edulazaro/laratext
Laratext lets Laravel apps define translations with both a key and readable text, keeping language files clear and stable. It auto-translates missing keys via OpenAI or Google Translate, plus offers a @text Blade directive, text() helper, and scan/update commands.
Install and publish the config
composer require edulazaro/laratext
php artisan vendor:publish --tag="texts"
If the tag publish silently does nothing (it happens when another package registers a conflicting tag), fall back to the provider form:
php artisan vendor:publish --provider="EduLazaro\Laratext\LaratextServiceProvider" --tag="texts"
Look at config/texts.php first. That single file is the whole surface area of the package: default_translator, the translators map, per-provider credentials/model/timeout/retries, the languages map, context, and the optional source_locale. Everything you will tune lives there. Before touching anything else, set:
'languages' => [
'en' => 'English',
'es' => 'Spanish',
],
'source_locale' => 'en', // only if it differs from config('app.locale')
'context' => 'A B2B invoicing app. Address users formally. Never translate the name Acme.',
and drop the matching key into .env:
OPENAI_API_KEY=sk-...
First use case: take one view or one Livewire component and swap the raw string for the helper. The pitch of the package is that you keep the key and the human-readable text in the same call, so the file stays legible and the key stays stable:
{{-- before --}}
<h1>{{ __('Welcome back') }}</h1>
{{-- after --}}
<h1>@text('dashboard.welcome', 'Welcome back')</h1>
Then run the scanner in the only mode that is free:
php artisan laratext:scan --dry
That prints new keys, drifted source texts, and orphan keys without writing files and without calling the translator. Once the report looks sane, php artisan laratext:scan --write --lang=es and inspect the diff with --diff. Do this on one screen before converting the rest of the app — you want to see what the translator does with your domain vocabulary before you hand it 5,000 keys.
Key convention: group by screen or domain, not by type. Because the scanner uses keys as a hint for the translator, nav.home gets you "Inicio", while common.name gets you nothing useful. Adopt area.element from day one (billing.invoice_due, auth.reset_password) and the auto-generated fallback text stays readable too:
text('hello_mate'); // "Hello Mate"
text('pages.contact_us'); // "Contact Us" — last segment only, dots dropped
Prefer the two-argument form in real code. The one-argument form is convenient for scaffolding, but the generated text is only a guess — it is also what gets compared against your source language file during drift detection, so a generated default that changes shape later looks like a source-text change. Explicit defaults are the stable path.
Placeholders and plurals are the same contract as __(). :name-style placeholders survive translation, and |-separated forms with a numeric count go through Laravel's own plural selector:
text('items.count', 'One item|You have :count items', ['count' => $n]);
text('items.count', '{0} No items|[1,19] You have :count items|[20,*] You have plenty', ['count' => $n]);
Remember that both signals are required: a pipe in the text and a numeric count in the replacements. text('shortcut', 'Ctrl|Alt') is returned verbatim, so pipes in non-plural copy are safe.
Scan workflow, in the order that saves money:
php artisan laratext:scan --dry # free; what would change?
php artisan laratext:scan --dry --only-missing # free; how much drift is there?
php artisan laratext:scan --write --diff # paid; write + review
--only-missing is the flag to reach for on a legacy codebase. By default, when the source text in code differs from what is stored for the source language, the key is treated as drifted and retranslated. On a codebase where copy gets edited weekly, that is a permanent token drain — --only-missing downgrades those to warnings and leaves the translation alone.
Treat translator choice as a per-run decision, not a permanent one. --translator=claude on a single scan, or change default_translator project-wide. The practical split: an LLM translator (OpenAI/Claude) when you need context, register, and product-name preservation; Google Translate for bulk, cheap, context-free passes. After switching model or provider, run --resync once so the whole catalog is regenerated under the new translator rather than left half-and-half.
Lock what the translator got wrong. LLM output is not deterministic across runs, and plural form counts are the translator's call — a language needing three forms may come back with two. Once a key is verified:
php artisan laratext:lock items.count --lang=ru
Do this especially for the keys with explicit ranges, legal copy, and product names.
Wire it into the everyday loop. Two habits pay off: run laratext:scan --dry in CI on pull requests and fail on "new keys" so missing translations are caught before deploy; and run --prune occasionally (never automatically) to clear orphan keys out of the lang files. Keep --diff in your local routine so a --write is never a blind commit.
Blade vs PHP. @text(...) and {{ text(...) }} are equivalent — use the directive in views for readability, the helper everywhere else (controllers, mailables, notifications, validation messages). The 3-argument form works identically in both.
The scanner is static — literal keys or nothing. text($key) or text("prefix.$name") still works at runtime if the key already exists, but it is invisible to the scanner, so it is never created and never reported as missing. A language that lacks it shows the raw key to the user, and nothing warns you. Write one literal call per branch:
// invisible to the scanner
return text("activity.{$this->type->value}", $default);
// visible
return match ($this->type->value) {
'created' => text('activity.created', ':actor created :item'),
'deleted' => text('activity.deleted', ':actor deleted :item'),
};
Only the key is constrained here — a double-quoted key containing $ is skipped, single quotes never interpolate (text('hol$a') is fine), and defaults plus replacements can be arbitrary expressions.
--write without --dry still costs money. Dropping --write does not mean "preview": the command translates and then discards the result, burning tokens the whole way. --dry is the only mode that skips the translator entirely. Make it muscle memory before any run you are unsure about.
source_locale mismatch is the silent budget killer. If APP_LOCALE=es but your source strings are authored in English, every key looks like its source text changed and a single --write retranslates the entire project. Set 'source_locale' => 'en' explicitly in any app where the running locale and the authoring language diverge. Omit it and behaviour is unchanged — but "unchanged" means "retranslate everything", which is the trap.
Review every translated plural. The translator decides how many forms each language gets, and languages with more forms than your source will get whatever the model produced. Spot-check Russian, Polish, Arabic and Czech output, then lock the verified keys.
Google ignores context. Filling in context with product names, register, and domain is high-leverage — but only for prompt-taking translators. Google Translate will translate "Book" as a noun or a verb with no way to know which. Do not debug a bad Google translation by rewriting context.
Keys are a hint, not an instruction. The prompt states keys may be used as context. nav.home helps; common.name says nothing, and a key inherited from an older part of the codebase can actively mislead. If a translation is wrong despite a sensible key, fix the source text or the context, not the key.
API failures hide behind retries. Each provider has timeout (60s OpenAI/Claude, 20s Google) and retries (3). A large scan is many batches; a mid-run failure can leave lang files partially updated. Run --diff after any run that errored, and re-run — --only-missing makes the resume cheap.
Lang files are the source of truth for behaviour, so back them up. --prune deletes keys not referenced in code. Any dynamically composed key from a legacy corner of the app will look like an orphan and get removed. Run --prune --dry first, read the orphan list, and only then commit.
Claude's prompt caching only helps within one scan. The system prompt is cached, so repeated batches in a single laratext:scan run reuse it automatically. Separate invocations do not share the cache — batch your work into one run rather than looping the command per language.
How can I help you explore Laravel packages today?