Laratext

Laratext names a string with a stable key and a readable default in the same call, then fills in the other languages for you from a scan of your source.
Installation
composer require edulazaro/laratext php artisan vendor:publish --tag="texts"
That publishes config/texts.php, where you list the languages you support and the service that translates them.
Naming a string
The core is one helper. You give it a key and the readable text as a default, and it returns the translation for the current locale, falling back to your default when there is not one yet.
text('auth.failed', 'These credentials do not match our records.');
In Blade the same thing is a directive.
@text('auth.failed', 'These credentials do not match our records.')
The key is stable, so you can reword the English freely. The sentence sits in the template, so you read it without opening a language file.
If you pass only a key with no default, the string still resolves: the scan turns a key like contact_us into a readable "Contact Us".
Placeholders
Placeholders use Laravel's :name syntax and take a replacement array. They survive translation, so :name still means :name in Spanish.
text('welcome.user', 'Welcome, :name!', ['name' => $user->name]);
Configuring languages and the translator
In config/texts.php you declare which languages you support and which service translates them.
'default_translator' => 'openai', 'translators' => [ 'openai' => EduLazaro\Laratext\Translators\OpenAITranslator::class, 'google' => EduLazaro\Laratext\Translators\GoogleTranslator::class, 'claude' => EduLazaro\Laratext\Translators\ClaudeTranslator::class, 'translatorio' => EduLazaro\Laratext\Translators\TranslatorioTranslator::class, ], 'languages' => [ 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French', ],
Four translators ship with the package. Pick one for a scan with --translator=, or make it the default with default_translator.
| Translator | --translator= |
Key in .env |
What it is |
|---|---|---|---|
| OpenAI | openai |
OPENAI_API_KEY |
OpenAI's chat models, gpt-5.4-nano unless OPENAI_MODEL says otherwise |
| Claude | claude |
ANTHROPIC_API_KEY |
Anthropic's Messages API, claude-haiku-4-5 unless ANTHROPIC_MODEL says otherwise |
| Google Translate | google |
GOOGLE_TRANSLATOR_API_KEY |
The Google Cloud Translation API |
| Translatorio | translatorio |
TRANSLATORIO_API_KEY |
A translation API that protects placeholders and tags itself |
The Claude translator runs with prompt caching, so repeated batches within a single scan reuse the cached instructions. context in the same file is a sentence about your application (what it does, the register it uses, the names it never translates) sent with every batch. Google Translate takes no prompt, so it ignores it; the other three read it.
What each model can do
Translating with gpt-3.5-turbo is not translating with gpt-5.4-nano: one answers 4,096 tokens at most and the other 128,000, one takes temperature and another refuses any request that sends it. The OpenAI and Claude translators know the models of both providers:
- Batches are sized by the answer they produce: a batch into twenty languages answers twenty times its size, so it is cut to fit one answer of the model, and languages go in groups when one string into all of them would not fit.
- An answer cut short at the limit is split in two, never sent again whole.
- Each model gets only the parameters it accepts:
temperaturewhere it is taken,reasoning_effortfor reasoning models, a JSON schema where Structured Outputs exist. - The source language is never sent, and
pt_BRorzh_CNare written under the code you use.
A model the package does not know is asked the way every model was before. Correct one or add a new one under models in config/texts.php:
'models' => [ 'gpt-6' => ['max_output_tokens' => 128000, 'temperature' => false, 'json' => 'schema', 'reasoning' => true, 'reasoning_effort' => 'low'], ],
Translating with Translatorio
Translatorio is a translation API that takes placeholders (:name, , %s), HTML tags and links out of each string before the model reads it and puts them back afterwards, then says whether every one came back. The scan prints a warning for a key whose placeholder did not survive instead of writing a broken string.
# .env TRANSLATORIO_API_KEY=tr_live_... TRANSLATORIO_GLOSSARY=shop # optional: one of your glossaries TRANSLATORIO_FORMALITY=informal # optional
php artisan laratext:scan --write --translator=translatorio
Laravel codes such as pt_BR are written back under the same code, more than twenty languages go in several calls, and the context of config/texts.php travels with every batch. Retries reuse the same idempotency key, so a retried batch is never charged twice. Running out of credits halfway keeps what was already translated, and the next scan finishes the rest. A tr_test_ key runs the whole scan for free and writes marked copies, like [es] Welcome, without reaching the model.
The scan command
The scan walks your PHP and Blade files, collects every text() and @text key with its default, works out which are missing from each language, translates only those, and writes them into lang/es.json, lang/fr.json and so on.
php artisan laratext:scan --write
The base language is your app.locale; the targets are the languages in the config.
| Option | What it does |
|---|---|
--dry |
Preview without writing |
--diff |
Show what would change |
--lang=es |
Restrict to one language |
--translator=translatorio |
Override the configured service |
--resync |
Retranslate keys whose source text actually changed, not only the missing ones |
Writing your own translator
The translation service is an interface, so you can point it at anything: a different API, a glossary-aware endpoint, a human review queue.
php artisan make:translator DeepLTranslator
That scaffolds a class extending the package's Translator base and implementing TranslatorInterface. You implement one method, which translates a single string into every target language and returns them keyed by language code.
namespace App\Translators; use EduLazaro\Laratext\Contracts\TranslatorInterface; use EduLazaro\Laratext\Translator; class DeepLTranslator extends Translator implements TranslatorInterface { public function translate(string $text, string $from, array $to): array { $results = []; foreach ($to as $language) { $results[$language] = $this->callDeepL($text, $from, $language); } return $results; } }
Extending the base Translator hands you batchTranslate, which splits a large set of strings into safe batches capped by character count and item count.
If your API accepts many strings per call, override translateMany to translate a whole batch in one request instead of one call per string. That is the difference between one round trip and a hundred.
Register the class in the translators map in config/texts.php and select it with --translator, or set it as the default.
built and maintained by Edu Lazaro · MIT license