minhyung / laravel-translator
Unified translation services (DeepL, Google Cloud Translation, ...) for Laravel.
Requires
- php: ^8.3
- deeplcom/deepl-php: ^1.19
- google/auth: ^1.52
- guzzlehttp/guzzle: ^7.0
- illuminate/bus: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- mozex/anthropic-php: ^1.7
- openai-php/client: ^0.20.0
- psr/log: ^3.0
Requires (Dev)
- aws/aws-sdk-php: ^3.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
- aws/aws-sdk-php: Required to use the Amazon Translate driver (^3.0).
README
English | 한국어
A Laravel package that puts multiple translation services (DeepL, Google Cloud Translation, LLMs, ...) behind one unified API.
You define named translators in config, each picking an implementation with a driver key, and select one with Translator::via('name'). Result caching is built in.
Built-in drivers:
deepl— DeepLgoogle— Google Cloud Translation (v2 by default; v3/Advanced viaversion)claude— native Anthropic Messages API via mozex/anthropic-phpopenai— OpenAI and any OpenAI-compatible endpoint (DeepSeek, Gemini, Groq, Mistral, xAI, OpenRouter, Ollama, self-hosted gateways) via openai-php/client, pointed withbase_urlazure— Azure AI Translator (Translator REST API v3.0)amazon— Amazon Translate via aws/aws-sdk-php (optional dependency)libretranslate— LibreTranslate (free/open-source, self-hosted or hosted)fallback— try several translators in order
No heavyweight LLM abstraction layer — each driver talks to its provider's SDK/API directly.
Requirements
- PHP
^8.3 - Laravel 12 / 13 (
illuminate/support: ^12.0|^13.0)
The Google driver defaults to Translation API v2, which works with an API key alone — no service-account credentials or the
ext-grpcPECL extension required. v3 (Advanced) is opt-in via'version' => 3and authenticates with a service account / Application Default Credentials (REST only — still no gRPC).
Installation
composer require minhyung/laravel-translator
Publish the config file (optional):
php artisan vendor:publish --tag=translator-config
Configuration
In config/translator.php or your .env:
TRANSLATOR_DEFAULT=deepl # default translator name: deepl | google | claude | openai | ... # DeepL DEEPL_AUTH_KEY=xxxxxxxx:fx # Google Cloud Translation (v2, API key) GOOGLE_TRANSLATE_KEY=AIza... # Claude (native) ANTHROPIC_API_KEY=sk-ant-... TRANSLATOR_CLAUDE_MODEL=claude-haiku-4-5 # OpenAI + compatible providers — API key + optional model override OPENAI_API_KEY=sk-... TRANSLATOR_OPENAI_MODEL=gpt-5.4-mini GEMINI_API_KEY=AIza... TRANSLATOR_GEMINI_MODEL=gemini-3-flash-preview DEEPSEEK_API_KEY=sk-... # Azure AI Translator AZURE_TRANSLATOR_KEY=xxxxxxxx AZURE_TRANSLATOR_REGION=koreacentral # optional for global keys # Amazon Translate (needs aws/aws-sdk-php; omit key/secret to use the AWS credential chain) AWS_DEFAULT_REGION=us-east-1 AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... # Caching TRANSLATOR_CACHE=true TRANSLATOR_CACHE_STORE= # empty = the application's default store TRANSLATOR_CACHE_TTL=86400 # seconds; empty = cache forever
Defining translators
Each entry under translators is a named instance whose driver picks the implementation.
Several names may share one driver — e.g. DeepSeek and Gemini both use the openai driver with their own base_url:
// config/translator.php 'translators' => [ 'deepl' => ['driver' => 'deepl', 'key' => env('DEEPL_AUTH_KEY')], 'google' => ['driver' => 'google', 'key' => env('GOOGLE_TRANSLATE_KEY')], 'claude' => ['driver' => 'claude', 'key' => env('ANTHROPIC_API_KEY'), 'model' => 'claude-haiku-4-5'], 'openai' => ['driver' => 'openai', 'key' => env('OPENAI_API_KEY'), 'model' => 'gpt-5.4-mini'], // OpenAI-compatible endpoints: same driver, different base_url 'gemini' => [ 'driver' => 'openai', 'base_url' => 'https://generativelanguage.googleapis.com/v1beta/openai', 'key' => env('GEMINI_API_KEY'), 'model' => 'gemini-3-flash-preview', ], 'deepseek' => [ 'driver' => 'openai', 'base_url' => 'https://api.deepseek.com/v1', 'key' => env('DEEPSEEK_API_KEY'), 'model' => 'deepseek-v4-flash', 'options' => [ // DeepSeek V4 enables "thinking" by default; disable it for translation. 'extra_body' => ['thinking' => ['type' => 'disabled']], ], // 'headers' => ['X-Tenant' => 'acme'], // extra HTTP headers ], ],
For the openai driver, options accepts temperature, max_tokens, system_prompt, and extra_body (arbitrary top-level request-body fields merged into the call, like the OpenAI SDK's extra_body — used above to turn off DeepSeek's thinking mode).
Common base_urls for the openai driver: DeepSeek https://api.deepseek.com/v1, Gemini https://generativelanguage.googleapis.com/v1beta/openai, Groq https://api.groq.com/openai/v1, Mistral https://api.mistral.ai/v1, xAI https://api.x.ai/v1, OpenRouter https://openrouter.ai/api/v1, Ollama http://localhost:11434/v1.
The libretranslate driver takes a base_url (defaults to https://libretranslate.com) and an optional key — only keyed instances need one:
'libretranslate' => [ 'driver' => 'libretranslate', 'base_url' => env('LIBRETRANSLATE_URL', 'http://localhost:5000'), 'key' => env('LIBRETRANSLATE_API_KEY'), // optional ],
The google driver stays google for both API versions — pick with version. v2 (default) takes an API key; v3 (Advanced) takes a project_id (and optional location) and authenticates with a service account or Application Default Credentials:
'google' => [ 'driver' => 'google', 'version' => 3, 'project_id' => env('GOOGLE_CLOUD_PROJECT'), 'location' => 'global', 'credentials' => env('GOOGLE_APPLICATION_CREDENTIALS'), // service-account JSON path; null = ADC ],
The azure driver takes a subscription key. A region is required for regional and multi-service resources (global/single-service keys may omit it); override endpoint for sovereign clouds:
'azure' => [ 'driver' => 'azure', 'key' => env('AZURE_TRANSLATOR_KEY'), 'region' => env('AZURE_TRANSLATOR_REGION'), // e.g. "koreacentral"; optional for global keys ],
The amazon driver needs the AWS SDK — composer require aws/aws-sdk-php — and a region. Omit key/secret to use the AWS default credential chain (env vars, ~/.aws, IAM instance/task role, ...):
'amazon' => [ 'driver' => 'amazon', 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'key' => env('AWS_ACCESS_KEY_ID'), // optional 'secret' => env('AWS_SECRET_ACCESS_KEY'), // optional ],
Translator::via('claude')->translate('Hello', 'ko'); // result's ->translator is "claude"
Usage
Single translation
use Minhyung\LaravelTranslator\Facades\Translator; $result = Translator::translate('Hello, world!', 'ko'); $result->text; // "안녕하세요, 세상!" $result->detectedSourceLang; // "en" $result->translator; // "deepl" (string) $result; // the translated text (Stringable)
Specify the source language and pass options:
Translator::translate('How are you?', 'de', 'en', ['formality' => 'less']);
Batch translation (keys and order preserved)
$results = Translator::translateBatch( ['greeting' => 'Hello', 'farewell' => 'Goodbye'], 'ko', ); $results['greeting']->text; // "안녕하세요" $results['farewell']->text; // "안녕히 가세요"
For LLM drivers (
claude,openai), batch translation requests a single JSON object with one in-order result per input and throws if the counts don't match.deepl,azure, andlibretranslatetranslate batches natively;amazon(whose real-time API is one text per call) loops, always preserving order and keys.
Into several languages at once
$results = Translator::translateInto(['ko', 'ja', 'es'], 'Hello'); // keyed by target $results['ko']->text; // "안녕하세요" $results['ja']->text; // "こんにちは"
Selecting a translator
Translator::via('google')->translate('Hello', 'ko'); // Per-call options Translator::via('openai')->translate('Hello', 'ko', 'en', [ 'temperature' => 0.0, 'system_prompt' => 'Translate from {source} into {target}. Keep it formal.', ]);
Building a translator at runtime
Need a translator that isn't in your config — e.g. per-tenant credentials? Build one on the fly from an inline config array (same shape as a config entry). The result is a normal Translator (uncached):
$translator = Translator::build([ 'driver' => 'openai', 'base_url' => 'https://api.deepseek.com/v1', 'key' => $tenant->deepseek_key, 'model' => 'deepseek-v4-flash', ]); $translator->translate('Hello', 'ko');
Pass a second argument to name it (used on the result's ->translator and in events).
Dependency injection
The Contracts\Translator contract is bound to the default translator.
use Minhyung\LaravelTranslator\Contracts\Translator; public function __construct(private Translator $translator) {}
Language detection
Drivers that can detect a language — google (v2 and v3), azure, and libretranslate — expose detect():
use Minhyung\LaravelTranslator\Facades\Translator; $detection = Translator::via('google')->detect('Bonjour le monde'); $detection->language; // "fr" $detection->confidence; // 0.98 (0–1, when the provider reports it) (string) $detection; // "fr"
Detection flows through caching, retries, and fallback like translation does. Calling detect() on a translator whose driver can't detect (e.g. deepl, openai) throws a clear error.
Supported languages
Drivers that can enumerate their languages — deepl, google (v2 and v3), azure, amazon, and libretranslate — expose languages():
foreach (Translator::via('deepl')->languages() as $language) { $language->code; // "EN-US" $language->name; // "English (American)" $language->source; // can be a source language? $language->target; // can be a target language? }
Glossaries
Glossaries (DeepL) and custom terminologies (Amazon Translate) are named sets of
source → target term overrides. Drivers that can manage them — deepl and
amazon — expose a single, unified API:
use Minhyung\LaravelTranslator\Facades\Translator; // Create one from a source-term => target-term map. $glossary = Translator::via('deepl')->createGlossary( name: 'product-terms', sourceLang: 'en', targetLang: 'ko', entries: ['cookie' => '쿠키', 'cache' => '캐시'], ); $glossary->id; // provider identifier $glossary->entryCount; // 2 $glossary->targetLangs; // ['ko'] (Amazon terminologies may list several) $glossary->ready; // usable for translation yet? Translator::via('deepl')->glossaries(); // list all Translator::via('deepl')->glossary($glossary->id); // fetch one Translator::via('deepl')->glossaryEntries($id); // ['cookie' => '쿠키', ...] Translator::via('deepl')->deleteGlossary($id);
Apply a glossary to a translation with the portable glossary option — it maps
to DeepL's glossary id and to Amazon's TerminologyNames:
Translator::via('deepl')->translate('Clear the cache', 'ko', 'en', [ 'glossary' => $glossary->id, ]);
Management flows through caching and retries like translation does. Calling these
on a translator whose driver can't manage glossaries (e.g. google, azure,
openai) throws a clear error.
Reading entries from
amazondownloads the terminology file Amazon returns, so that translator needs the HTTP client (wired automatically by the package).
Command line
Translate a string straight from the terminal:
php artisan translator:translate "Hello, world!" ko # 안녕하세요, 세상! # pick a translator and source language php artisan translator:translate "Hello" ko --via=claude --from=en # full result (text, detected source, translator, ...) as JSON php artisan translator:translate "Hello" ko --json
Options: --from (source language, auto-detected when omitted), --via (translator name, the default when omitted), --json (output the full result as JSON).
Translate your localization files (PHP groups and the JSON file) into other locales, preserving array structure, :placeholder tokens, and pluralization (apple|apples, {1} :count …). By default only missing keys are filled, so it's safe to re-run:
php artisan translator:lang ko ja # en → ko and ja php artisan translator:lang de --via=deepl # use a specific translator php artisan translator:lang ko --overwrite # re-translate existing keys too
Options: --source (source locale, default en), --via (translator), --overwrite (re-translate keys that already exist).
Check that your configuration is sound — each translator is built and validated (missing keys/models, unknown fallback children, an undefined default, ...) and reported in a table:
php artisan translator:doctor
# also attempt a live translation through each translator
php artisan translator:doctor --ping
It exits non-zero when something is misconfigured, so it works in CI.
Caching
When translator.cache.enabled is on, every driver is wrapped in a CachingDriver.
Identical inputs (text · source/target language · options) are served straight from the Laravel cache, cutting API calls and cost.
For batch translation, only the cache misses are sent to the provider in a single call.
Retries
Any translator can shrug off transient provider errors (timeouts, 429/5xx) by adding a retry key — an attempt count, or ['times' => , 'sleep' => ] (sleep is the base backoff in ms, multiplied by the attempt number):
'openai' => [ 'driver' => 'openai', 'key' => env('OPENAI_API_KEY'), 'model' => 'gpt-5.4-mini', 'retry' => ['times' => 3, 'sleep' => 200], // or just: 'retry' => 3 ],
Retries sit inside caching (a cache hit never retries) and apply per translator — including each child of a fallback, so a provider self-heals before the chain moves on.
Failover
To automatically switch to the next provider when one fails, define a translator with the fallback driver.
It tries each listed translator in order and moves on to the next whenever one throws.
// config/translator.php 'default' => 'safe', 'translators' => [ 'deepl' => ['driver' => 'deepl', 'key' => env('DEEPL_AUTH_KEY')], 'claude' => ['driver' => 'claude', 'key' => env('ANTHROPIC_API_KEY'), 'model' => 'claude-haiku-4-5'], 'safe' => [ 'driver' => 'fallback', 'translators' => ['deepl', 'claude'], ], ],
Translator::translate('Hello', 'ko'); // if deepl fails, try claude
- Each child translator is cached individually (the
fallbackitself is not cached, to avoid double caching). Every fallback attempt is logged atwarninglevel via a PSR logger and dispatches aTranslationFellBackevent. - If every translator fails, an
AllTranslationDriversFailedExceptionis thrown; usegetErrors()to get the underlying exception per translator.
Queued translation
Translate in the background instead of inline with queue() / queueBatch(). Each dispatches a TranslateJob onto the queue:
Translator::queue('Hello', 'ko', 'en'); // default translator Translator::via('deepl')->queue('Hello', 'ko'); // a specific one Translator::via('deepl')->queueBatch(['Hello', 'Bye'], 'ko');
The job re-resolves the translator by name on the worker, so it always uses the current config and caching/retry wrapping. Because the work runs in the background, results are delivered through the lifecycle events rather than returned — listen for TranslationCompleted / BatchTranslationCompleted to act on them. TranslationFailed is dispatched once the queue has exhausted the job's retries (from the job's tries or the worker's --tries), not on every failed attempt, so it reflects the ultimate failure.
The job's connection, queue, tries, and backoff come from the translator.queue config — point translations at a dedicated queue/connection there. In tests, fake the queue and assert what was pushed:
use Illuminate\Support\Facades\Queue; use Minhyung\LaravelTranslator\Jobs\TranslateJob; Queue::fake(); Translator::queue('Hello', 'ko'); Queue::assertPushed(TranslateJob::class);
Events
The package dispatches lifecycle events you can listen for:
| Event | When |
|---|---|
Events\TranslationCompleted |
a single translate() succeeded (->translator, ->text, ->result, ->sourceLang, ->options) |
Events\BatchTranslationCompleted |
a translateBatch() succeeded (->translator, ->texts, ->results, ->targetLang, ...) |
Events\TranslationFailed |
a request ultimately failed (->translator, ->exception, ->texts, ...) |
Events\TranslationFellBack |
a fallback child threw and the chain moved on (->translator, ->exception) |
use Illuminate\Support\Facades\Event; use Minhyung\LaravelTranslator\Events\TranslationCompleted; Event::listen(function (TranslationCompleted $event) { logger()->info("Translated via {$event->translator}: {$event->result->text}"); });
Architecture
There are two layers:
Contracts\Driver— the low-level provider contract. Each provider is an adapter implementing it (DeeplDriver,OpenAiDriver, ...), as are the compositeCachingDriverandFallbackDriver.Translator(implementsContracts\Translator) — the public object the manager hands back fromvia()and binds for injection. It wraps aDriverand delegates to it, exposing->driver()and->name().
So Translator::via('claude') returns a Translator wrapping a (cache-wrapped) ClaudeDriver.
Extending with a custom driver
Implement Contracts\Driver and register it on the manager. The callback receives ($container, $name, $config) and returns a Driver; reference it from config with 'driver' => 'papago'.
use Minhyung\LaravelTranslator\TranslatorManager; app(TranslatorManager::class)->extend('papago', function ($container, $name, $config) { return new \App\Translation\PapagoDriver($config['key'], $name); // implements Contracts\Driver });
// config/translator.php 'translators' => [ 'papago' => ['driver' => 'papago', 'key' => env('PAPAGO_KEY')], ],
Testing
In your application's tests, call Translator::fake() so no real provider is hit. It records every translation and returns canned results — by default it echoes the source text; pass a map or a closure to control the output:
use Minhyung\LaravelTranslator\Facades\Translator; $fake = Translator::fake([ 'Hello' => '안녕하세요', // map source => translation (unknown texts echo) ]); // or: Translator::fake(fn (string $text, string $target) => "[$target] $text"); // ... exercise code that translates ... $fake->assertTranslated('Hello'); $fake->assertTranslated('Hello', fn (array $r) => $r['target'] === 'ko'); // match a predicate $fake->assertTranslatedTimes('Hello', 1); $fake->assertNotTranslated('Goodbye'); $fake->assertNothingTranslated(); $fake->assertTranslatedCount(1);
Translator::fake() swaps the manager in the container, so the facade, Translator::via()/build(), and an injected Contracts\Translator all record through the fake.
Contributing
composer install vendor/bin/pest
License
MIT