minhyung/laravel-translator

Unified translation services (DeepL, Google Cloud Translation, ...) for Laravel.

Maintainers

Package info

github.com/overworks/laravel-translator

pkg:composer/minhyung/laravel-translator

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.5.0 2026-06-30 15:24 UTC

This package is auto-updated.

Last update: 2026-06-30 15:31:30 UTC


README

Latest Version on Packagist Tests Total Downloads License

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 — DeepL
  • google — Google Cloud Translation (v2 by default; v3/Advanced via version)
  • claude — native Anthropic Messages API via mozex/anthropic-php
  • openai — OpenAI and any OpenAI-compatible endpoint (DeepSeek, Gemini, Groq, Mistral, xAI, OpenRouter, Ollama, self-hosted gateways) via openai-php/client, pointed with base_url
  • azure — Azure AI Translator (Translator REST API v3.0)
  • amazon — Amazon Translate via aws/aws-sdk-php (optional dependency)
  • libretranslateLibreTranslate (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-grpc PECL extension required. v3 (Advanced) is opt-in via 'version' => 3 and 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, google, azure, and libretranslate translate 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 amazon downloads 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 fallback itself is not cached, to avoid double caching). Every fallback attempt is logged at warning level via a PSR logger and dispatches a TranslationFellBack event.
  • If every translator fails, an AllTranslationDriversFailedException is thrown; use getErrors() 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 composite CachingDriver and FallbackDriver.
  • Translator (implements Contracts\Translator) — the public object the manager hands back from via() and binds for injection. It wraps a Driver and 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