Google Translate, the Laravel way.
Calling the Cloud Translation API is four endpoints' worth of work. Everything hard about translating in a real application happens around that call — and that is what this package is.
What it does
GoogleTranslateToolkit is a fluent, immutable builder over the v2 REST endpoint, built on Laravel's own HTTP client. No Google SDK, no gRPC, no protobuf shims — which means Http::fake(), Http::pool() and every retry helper you already know work here unchanged.
- 01It caches, and the cache is the point
Cached segments never reach the API. A batch of a hundred strings only sends the ones it has not seen before, so the second deploy of the same content costs nothing.
- 02Placeholders come back intact
Ten patterns are masked before the call and restored after —
:name,{count},{{ $blade }},%s, URLs, e-mails, handles and code spans. Google never sees them, so it never translates them. - 03It refuses to surprise you on the invoice
estimate()prices a call before it happens, a daily character budget throws rather than bills, andtranslate:statsshows the hit rate that explains both. - 04It fails the way a queued job should
Timeouts, retry with backoff on 429 and 5xx, and
onFailUseSource()so a webhook stores the original text instead of failing the whole job.
Do you need it?
Two questions, and the second is the one worth being honest about.
- Is the text produced at runtime? Interface copy belongs in
lang/files, written by a person. If what you are translating arrives with a webhook, a user or an import, keep reading. - Would translating the same string twice be embarrassing? If yes, you want a cache, a budget and a hit rate — which is most of this package.
- Runtime content: webhooks, imports, user submissions
- Lang files you want machine-drafted, then reviewed
- Anything that must not fail when Google does
- Model columns mirrored per locale (
body→body_it)
- Interface copy a translator should write properly
- Legal, medical or safety text where accuracy is liability
- Data you may not send to a third party
- A single one-off string — paste it into Google yourself
Requirements
- PHP 8.3 or newer
- Laravel 12 or 13
- A Google Cloud API key with the Cloud Translation API enabled
- No database tables, no migrations, no published assets
Http::fake() with stray requests blocked, and static analysis is clean at PHPStan level 6.Installed in one line.
The service provider is auto-discovered. There is nothing to register, nothing to migrate and nothing to publish before it works.
Install the package
$ composer require gabrielesbaiz/google-translate-toolkit
Publish the configuration
Optional, but you will want it the moment you set a glossary or a budget.
$ php artisan vendor:publish --tag="google-translate-toolkit-config"
Add your key
GOOGLE_TRANSLATE_API_KEY=your-cloud-console-key GOOGLE_TRANSLATE_TARGET=it # GOOGLE_TRANSLATE_SOURCE is deliberately left empty: # Google detects the source language, which costs the same # and is more accurate than guessing.
Getting an API key
- Open the Google Cloud console and select or create a project.
- Enable Cloud Translation API under APIs & Services → Library.
- Create an API key under APIs & Services → Credentials.
- Restrict it: API restrictions → Cloud Translation API, plus an IP restriction if your servers have fixed addresses.
X-goog-api-key header, so it does not end up in proxy logs, browser history or your error tracker.Verify
$ php artisan translate:text "Hello world" --to=it from to source translation origin auto it Hello world Ciao mondo api
php artisan config:clear — a config cached before you set the key will still be holding the old, empty value.Five minutes, start to finish.
Two facade names are registered and both resolve the same instance: GoogleTranslateToolkit (the 1.x name) and GoogleTranslate.
Translate something
use Gabrielesbaiz\GoogleTranslateToolkit\Facades\GoogleTranslate; // The short way — source auto-detected, target from config GoogleTranslate::justTranslate('The message bounced'); // "Il messaggio è stato rifiutato" // The full object $translation = GoogleTranslate::translate('Good morning'); $translation->translatedText; // "Buongiorno" $translation->sourceLanguage; // "en" — detected by Google $translation->cached; // false now, true next time (string) $translation; // "Buongiorno"
The builder
Every modifier returns a new instance, so a configured builder is safe to store and reuse. Nothing happens until a terminal method runs.
$italian = GoogleTranslate::from('en')->to('it'); $italian->text('Hello'); // string $italian->asHtml()->text($post->body); // a different builder $italian->many(['one', 'two']); // TranslationCollection // Many languages, one pooled fan-out GoogleTranslate::to(['it', 'fr', 'de'])->translate('Good morning'); // Collection keyed by locale
What comes back
| You pass | One target | Several targets |
|---|---|---|
| A string | Translation | Collection<locale, Translation> |
| An array | TranslationCollection | Collection<locale, TranslationCollection> |
Translation is readonly, but it implements ArrayAccess with the 1.x keys — $t['translated_text'] still works, as does toArray() and json_encode().Terminal methods
| Method | Returns |
|---|---|
translate($text) | A Translation, a collection, or one per locale |
text($string) | The translated string, nothing else |
many($iterable) | TranslationCollection |
lazy($iterable) | LazyCollection, chunked and pooled |
json($payload) | The same array shape, selected leaves translated |
detect($text) | DetectedLanguage |
roundTrip($text) | RoundTripResult with a 0–1 score |
estimate($texts) | Estimate — spends nothing |
Every key, and why it exists.
Every setting has a working default. Delete the ones you do not care about — the package falls back to what is documented here.
Credentials and defaults
'api_key' => env('GOOGLE_TRANSLATE_API_KEY', env('GOOGLE_DEVELOPER_KEY')), 'base_url' => env('GOOGLE_TRANSLATE_BASE_URL', 'https://translation.googleapis.com/language/translate/v2'), 'default_source' => env('GOOGLE_TRANSLATE_SOURCE'), // null = auto-detect 'default_target' => env('GOOGLE_TRANSLATE_TARGET'), // null = app.locale 'default_format' => 'text', // or 'html' 'strict_languages' => true,
default_source empty. Google detects the source language for the same price, and a string that is already Italian then stops being translated from English. GOOGLE_DEVELOPER_KEY is read as a fallback so 1.x applications keep working untouched.HTTP and chunking
| Key | Default | What it controls |
|---|---|---|
http.timeout | 10 | Seconds before a request is abandoned |
http.connect_timeout | 5 | Seconds to establish the connection |
http.retry.times | 3 | Attempts on connection errors, 429 and 5xx |
http.retry.sleep | 250 | Milliseconds before the first retry |
http.retry.backoff | true | Doubles the wait each attempt |
http.concurrency | 5 | Requests in flight per pool |
chunk.max_segments | 100 | Segments per request (the endpoint allows 128) |
chunk.max_characters | 20000 | Characters per request |
Cache, limits and resilience
| Key | Default | What it controls |
|---|---|---|
cache.enabled | true | The single biggest saving in the package |
cache.store | null | Which store to use; null is your default |
cache.ttl | 30 days | How long a translation stays valid |
cache.prefix | gtt | Key namespace, also used by the flush |
rate_limit.enabled | false | Guards the quota with Laravel's RateLimiter |
rate_limit.max_per_minute | 600 | Requests, not characters |
fallback_to_source | false | Return the source text instead of throwing |
budget.max_characters_per_day | null | Throws before the request is built |
events | true | Fires TranslationCompleted / TranslationFailed |
attribute_suffix | _{locale} | Column naming for HasTranslations |
Language codes
Codes are normalised before anything else happens: ZH_tw, zh-tw and zh-TW are the same language. With strict_languages on, an unknown code throws UnsupportedLanguageException rather than spending a request to find out.
use Gabrielesbaiz\GoogleTranslateToolkit\Enums\Language; Language::Italian->value; // "it" Language::ChineseTraditional->label(); // "Chinese (Traditional)" Language::Arabic->isRtl(); // true — 9 of the 137 are Language::normalize('ZH_tw'); // "zh-TW" Language::options(); // Collection<code, label> for a select
strict_languages to false and the code is passed straight through — then open an issue so the enum catches up.The part other packages get wrong.
Google will happily translate :name, reorder %s, break {{ $blade }} and put a space in the middle of a URL. This package masks those before the call and restores them after.
GoogleTranslate::justTranslate( 'Welcome back, :name — {count} items at https://example.com' ); // "Bentornato, :name — {count} articoli su https://example.com"
What is shielded out of the box
| Pattern | Example |
|---|---|
| Code blocks | <code>php artisan</code> |
| Backtick spans | `composer update` |
| Blade comments | {{-- hidden --}} |
| Blade / Handlebars | {{ $user->name }} |
| Curly placeholders | {count}, {first_name} |
| URLs | https://example.com/path?q=1 |
| E-mail addresses | support@example.com |
| Laravel placeholders | :name, :Name, :NAME |
| printf | %s, %2$d, %.2f |
| Handles | @gabrielesbaiz |
In plain-text mode the mask is ⟦0⟧; in HTML mode it is <span translate="no">0</span>, which Google is obliged to leave alone. Restoration is tolerant: if a space appears inside the sentinel, the package still finds it.
Adding your own
// Per call — a regex, or a literal that gets quoted for you GoogleTranslate::preserving(['/#[A-Za-z0-9_]+/', 'Acme Mailer']) ->text($post->body); // Globally 'placeholders' => [ 'enabled' => true, 'patterns' => ['/\[\[.*?\]\]/'], ], // Off, when you know the text is plain prose GoogleTranslate::query()->withoutPreserving()->text($text);
Glossary
Two different jobs share one config section. Protected terms are masked like a placeholder and never reach Google at all. Overrides are applied after the translation comes back, so you can force domain wording Google gets wrong.
'glossary' => [ 'protect' => ['Acme', 'Acme Mailer'], 'overrides' => [ 'it' => [ 'rimbalzo' => 'rifiuto', 'consegna fallita' => 'mancata consegna', ], ], ],
Rimbalzo becomes Rifiuto, RIMBALZO becomes RIFIUTO. Skip the whole mechanism for one call with withoutGlossary().Checking the result
Back-translation is the cheapest useful check there is: translate out, translate back, and score how much meaning survived.
$check = GoogleTranslate::roundTrip('The message was rejected', via: 'it'); $check->translated; // "Il messaggio è stato rifiutato" $check->back; // "The message was rejected" $check->score; // 1.0 $check->isSuspicious(0.6); // false
php artisan translate:audit runs it across a whole lang file and tells you the bill before it starts.Most translations never leave your server.
Translations are deterministic enough to cache, and caching them is the single biggest thing this package does for your bill.
How a batch is split
A batch is partitioned, not cached whole. Hits are served locally, and only the misses are sent — so adding one new string to a hundred old ones costs one string.
GoogleTranslate::translateBatch(['one', 'two']); // 1 request, 2 segments GoogleTranslate::translateBatch(['one', 'two', 'three']); // 1 request, 1 segment $t = GoogleTranslate::translate('one'); $t->cached; // true — it never went out
The key is sha1(source|target|format|text) under cache.prefix. Source, target and format are all part of it, so text and html are separate entries — as are two different target languages for the same string.
Controlling it
GoogleTranslate::withoutCache()->text('Hello'); // always calls GoogleTranslate::withCache(ttl: 3600)->text('Hello'); // one hour GoogleTranslate::flushCache(); // bumps a namespace version // php artisan translate:cache-clear // Pre-fill before a launch, on the queue GoogleTranslate::warm($strings, ['it', 'de', 'fr']);
Cache::flush(), so nothing else in your application loses its cache.Knowing the cost first
Google bills per million characters, per target language. Fan-out multiplies: three targets is three times the characters.
$estimate = GoogleTranslate::estimate($texts, ['it', 'fr']); $estimate->segments; // 128 $estimate->characters; // 8 402 $estimate->billableCharacters; // 16 804 — two targets $estimate->cachedSegments; // 31 already answered locally $estimate->requests; // 4 $estimate->formattedCost(); // "USD 0.3361"
A ceiling that holds
'budget' => ['max_characters_per_day' => 500_000],
BudgetExceededException is raised while the request is still being assembled, so crossing the ceiling costs nothing. Counters live in the cache store, keyed by day.Watching the spend
date calls characters cache hits hit rate cost 2026-09-20 41 62 118 388 90.4% $1.24 2026-09-21 12 18 402 419 97.2% $0.37 2026-09-22 9 11 088 402 97.8% $0.22 2026-09-23 6 7 640 511 98.8% $0.15
The same numbers are available in code through GoogleTranslate::stats(7), and the raw counters through usage().
When Google has a bad minute
// Throw — the default GoogleTranslate::justTranslate($text); // Return the source text instead, and carry on GoogleTranslate::onFailUseSource()->text($text); // Or globally, for every call 'fallback_to_source' => true,
Text handed back by the fallback is never written to the cache. It was never translated, so storing it would serve the source language for the whole 30-day lifetime after a single bad minute. The segment stays a miss, and the next call tries again.
| Exception | Raised when |
|---|---|
MissingApiKeyException | No key configured — thrown lazily, on first call |
UnsupportedLanguageException | Unknown code while strict_languages is on |
InvalidFormatException | A format other than text or html |
TranslationFailedException | Non-2xx, transport error, or a malformed payload |
RateLimitExceededException | Local rate limit reached; carries $secondsUntilAvailable |
BudgetExceededException | The daily character budget would be crossed |
Everything extends GoogleTranslateException, which extends RuntimeException — so catching \Exception still works exactly as it did in 1.x.
One column, one locale.
The convention is a sibling column per language — body and body_it — which is what most applications already do by hand.
The trait
use Gabrielesbaiz\GoogleTranslateToolkit\Concerns\HasTranslations; use Gabrielesbaiz\GoogleTranslateToolkit\Contracts\Translatable; class Message extends Model implements Translatable { use HasTranslations; public function translatableAttributes(): array { return ['body']; // → body_it } }
implements Translatable.Filling the columns
$message->translateAttributes()->save(); // default locale $message->translateAttributes(to: 'de', from: 'en')->save(); $message->translateAttributes(overwrite: true)->save(); // redo it $message->queueTranslateAttributes('it'); // off to the queue $message->getTranslatedAttribute('body', 'it'); $message->translatedAttributeName('body', 'it'); // "body_it"
overwrite: true, so a nightly backfill only pays for what arrived that day.Finding what is missing
Message::query()->whereTranslationMissing('body')->count(); Message::query()->whereTranslationMissing('body', 'de')->get();
The scope matches rows where the source column has content and the translated column does not — which is exactly what translate:model walks.
Backfilling a table
$ php artisan translate:model "App\Models\Message" --to=it --chunk=500 $ php artisan translate:model "App\Models\Post" --to=de --queue --limit=2000
After the response, not during it
For a webhook that must answer in milliseconds, deferred() runs the translation once the response has been sent, using Laravel's defer().
GoogleTranslate::deferred()->translate($description, function ($translation) use ($message) { $message->update(['body_it' => $translation->translatedText]); });
defer() only defers where there is a response to send first.Events
| Event | Carries |
|---|---|
TranslationCompleted | translations, target, source, characters, cacheHits, requests |
TranslationFailed | exception, texts, target, source, recovered |
recovered is true when the failure was absorbed by onFailUseSource() — useful for a log line that distinguishes "degraded" from "broken". A recovered result is not cached, so the next call retries rather than serving the source text for the rest of the TTL.
Every method, in one place.
The facade proxies a single singleton. Anything that opens a builder returns a PendingTranslation; anything that ends one returns data.
Opening a builder
| Method | Does |
|---|---|
query() | A fresh builder carrying the configured defaults |
from($language) | Sets the source; null lets Google detect |
to($language|$languages) | One target, or several for a pooled fan-out |
format($format) · asText() · asHtml() | Output format |
withCache(?$ttl) · withoutCache() | Cache behaviour for this call |
onFailUseSource($bool) · onFailThrow() | What a failure does |
preserving(array) · withoutPreserving() | Placeholder shielding |
withoutGlossary() | Skip protected terms and overrides |
detectSource() | Force auto-detection, ignoring the configured source |
deferred($bool) | Run after the response has been sent |
when() · unless() | Conditionable, as everywhere else in Laravel |
Translating and detecting
| Method | Returns |
|---|---|
translate($text, $from, $to, $format) | Translation, TranslationCollection, or a collection per locale |
justTranslate($text, $from, $to) | string — the 1.x shorthand |
translateBatch($texts, …) | TranslationCollection |
translateJson($payload, $only, $except, …) | The same array shape, selected leaves translated |
lazy($texts, $from, $to, $chunkSize) | LazyCollection<Translation> |
unlessLanguageIs($code, $text, …) | Translation; check isUnchanged() |
isLanguage($text, $language) | bool |
detect($text) | DetectedLanguage or a collection of them |
detectLanguage() · detectLanguageBatch() | 1.x names, same objects |
Languages, quality and cost
| Method | Returns |
|---|---|
languages($display) | What Google supports, named and cached for a day |
supportedLanguages() | The bundled enum — no API call |
getAvailableTranslationsFor($code) | 1.x alias of languages() |
sanitizeLanguageCode($code) | Normalised code, or throws |
roundTrip($text, $via, $from) | RoundTripResult with a 0–1 score |
estimate($texts, $targets) | Estimate — spends nothing |
usage() · stats($days) | Character accounting |
warm($texts, $targets, $from) | A queued Bus::batch() that fills the cache |
flushCache() | Bumps the cache namespace version |
fake($stubs) | FakeTranslator, bound into the container |
Data objects
| Class | Members |
|---|---|
Translation | sourceText, translatedText, sourceLanguage, targetLanguage, format, detected, cached; isUnchanged(), toArray(), toJson() |
DetectedLanguage | text, languageCode, confidence, reliable; language(), is() |
TranslationCollection | texts(), dictionary(), toStrings(), plus everything on Collection |
Estimate | segments, characters, billableCharacters, cachedSegments, requests, targets, cost; formattedCost() |
RoundTripResult | source, translated, back, score; isSuspicious() |
Blade, macros and helpers
{{-- target first, source second --}} @translate($post->title) @translate($post->title, 'fr') @translate($post->title, 'fr', 'en') @translateHtml($post->body, 'fr')
Str::translate('hello', 'fr'); str('hello')->translateTo('de')->upper(); str('bonjour')->detectLanguage(); // "fr" collect(['a', 'b'])->translate('fr'); google_translate('hello', 'es');
Str::translate() wins if you already have one.Validation and casting
$request->validate([ 'locale' => ['required', Rule::language()], 'target' => ['required', Rule::language(['it', 'en', 'fr'])], ]); protected function casts(): array { return ['locale' => AsLanguage::class]; // Language enum in, ISO code out }
Swapping the driver
Bind anything implementing the Translator contract — a different provider, an on-premise model, or a canned dataset for a demo tenant. Cache, placeholders, glossary, budget, events, Eloquent and the commands all keep working on top of it.
$this->app->singleton(Translator::class, DeepLTranslator::class);
Eight commands, no guesswork.
Translate a string, a lang file or a whole table — and price any of it before you spend a character.
translate:text
$ php artisan translate:text "Hello world" "Good evening" --to=it --to=fr $ php artisan translate:text "Hello" --from=en --to=de --format=html --no-cache $ php artisan translate:text "Hello" --to=it --dry-run # price only
| Flag | Meaning |
|---|---|
--from= | Source code; omit it and Google detects |
--to=* | One or more targets |
--format= | text (default) or html |
--no-cache | Bypass the cache for this run |
--dry-run | Report the cost, send nothing |
--json | Raw JSON output |
translate:lang
Reads lang/{from}/*.php and lang/{from}.json, translates only the lines the target does not have yet, and writes formatted PHP and JSON back. Placeholder protection is on, so :attribute and {count} survive. Nested arrays keep their structure and keys are sorted.
$ php artisan translate:lang --from=en --to=it --to=fr --dry-run INFO [it] 214 lines would be translated (18 402 characters, USD 0.3680). INFO [fr] 214 lines would be translated (18 402 characters, USD 0.3680). $ php artisan translate:lang --from=en --to=it --group=auth --group=validation
| Flag | Meaning |
|---|---|
--from= | Source locale, default en |
--to=* | Target locales |
--group=* | Only these files; __json is the JSON file |
--force | Overwrite lines that already exist |
--dry-run | Count and price, write nothing |
translate:model
| Flag | Meaning |
|---|---|
--to= / --from= | Target and source locales |
--attributes=* | Limit to these attributes |
--chunk= | Rows per chunk, default 200 |
--limit= | Stop after this many rows |
--queue | Dispatch a job per row instead of translating inline |
--overwrite | Retranslate rows that already have a value |
The rest
$ php artisan translate:languages --target=it --search=chin $ php artisan translate:languages --offline # no API call $ php artisan translate:audit --to=it --threshold=0.7 --limit=200 $ php artisan translate:cost --file=storage/app/strings.txt --to=it $ php artisan translate:stats --days=30 $ php artisan translate:cache-clear
translate:model --queue pairs well with a nightly schedule: the command only queues rows the scope says are missing, so a quiet night queues nothing.No network, ever.
Two ways to test code that translates: the package's own fake, or Laravel's HTTP fake. Both are first-class, because underneath it really is just an HTTP client.
The fake
$fake = GoogleTranslate::fake(); $this->postJson('/webhooks/postmark', $payload)->assertOk(); $fake->assertTranslated('The recipient rejected the message') ->assertTranslatedTo('it') ->assertTranslatedCount(1);
Without stubs the fake returns "[it] the original text", which keeps failure messages readable — you can see at a glance what actually ran.
Stubbing
GoogleTranslate::fake([ 'Hello' => 'Ciao', // any target 'Bye' => ['it' => 'Ciao ciao', 'fr' => 'Au revoir'], // per target 'Dynamic' => fn ($text, $target) => "$target:$text", // computed ])->detectAs('fr');
Assertions
| Assertion | Checks |
|---|---|
assertTranslated($text, ?$target) | That text was translated; accepts a closure |
assertNotTranslated($text) | That it was not |
assertNothingTranslated() | No translation happened at all |
assertTranslatedCount($n) | Exactly $n translation calls |
assertTranslatedTo($locale) | Something was translated into that locale |
assertDetected(?$text) | Detection ran, optionally for that text |
The recording is open too: recorded(), recordedDetections() and translatedTexts() return collections you can assert on directly.
Asserting on the wire
Http::fake(['*translate/v2*' => Http::response([ 'data' => ['translations' => [['translatedText' => 'Ciao mondo']]], ])]); GoogleTranslate::justTranslate('Hello world'); Http::assertSent(fn ($request) => $request['target'] === 'it' && $request->hasHeader('X-goog-api-key') );
Http::preventStrayRequests() to your TestCase and any forgotten fake becomes a failing test instead of a live API call. That is how this package's own 94 tests are written.config()->set('google-translate-toolkit.cache.enabled', false).Four jobs, worked through.
Each of these is a real shape the package was built for, written the way it would be written in an application.
A webhook that must never fail
The job's real work is storing a bounce record. A translation that fails should degrade, not take the record with it.
$message = Message::query()->updateOrCreate(['id' => $payload['MessageID']], [ 'body' => $payload['Description'], 'body_it' => GoogleTranslate::onFailUseSource() ->text($payload['Description']), ]);
Or push it past the response entirely, so the endpoint answers in milliseconds:
GoogleTranslate::deferred()->translate($description, fn ($t) => $message->update([ 'body_it' => $t->translatedText, ]));
Drafting a whole application's lang files
$ php artisan translate:lang --from=en --to=it --to=fr --to=de --dry-run $ php artisan translate:lang --from=en --to=it --to=fr --to=de $ php artisan translate:audit --to=it --threshold=0.65
An API resource in the caller's language
public function toArray($request): array { return GoogleTranslate::to($request->user()->locale)->json([ 'id' => $this->id, 'title' => $this->title, 'body' => $this->body, 'slug' => $this->slug, ], only: ['title', 'body']); }
warm() after a content import.A nightly backfill
Schedule::command('translate:model', [ 'App\Models\Message', '--to' => 'it', '--queue', ])->dailyAt('02:00');
The command only walks rows the whereTranslationMissing scope returns, so a quiet night queues nothing and costs nothing.
When it doesn't do what you meant.
Seven symptoms that cover almost everything, with the cause rather than a workaround.
MissingApiKeyException
GOOGLE_TRANSLATE_API_KEY is unset — or the config was cached before you set it. Run php artisan config:clear. The exception is thrown lazily on the first call, not at boot, so a missing key never breaks an unrelated deploy.
"API key not valid"
The key exists, but the Cloud Translation API is not enabled on that project, or a key restriction is blocking it. Check APIs & Services → Library first, then the key's restrictions.
UnsupportedLanguageException for a code Google supports
The bundled enum is behind. Set 'strict_languages' => false to pass the code straight through, and please open an issue so the enum catches up.
Placeholders still come back broken
The pattern is not one of the ten built-ins. Add it with preserving(['/your-pattern/']) or in placeholders.patterns. Anything that looks like /…/ is treated as a regex; anything else is quoted as a literal for you.
Translations don't change after a glossary edit
The old value is cached — the glossary is applied before the result is stored. php artisan translate:cache-clear, then try again.
The same string is translated twice
Check cache.enabled, then remember what the key is made of: source, target, format and text. text and html are separate entries, and so are two targets for one string.
Tests hit the network
Call GoogleTranslate::fake() or Http::fake(). Add Http::preventStrayRequests() to your TestCase and a forgotten fake becomes a failing test rather than a live call.
What you're relying on.
A small package with a narrow job, tested properly, and honest about what it sends where.
Requirements and support
- PHP 8.3 or newer
- Laravel 12 or 13, both covered by the test matrix
- A Google Cloud API key with the Cloud Translation API enabled
- No migrations, no tables, no published assets
What ships in the box
| Figure | Value |
|---|---|
| Languages in the enum | 137, nine of them right to left |
| Protected placeholder patterns | 10, extendable |
| Artisan commands | 8 |
| Tests | 94, 192 assertions, none of them touching the network |
| Static analysis | PHPStan level 6, clean |
| Runtime dependencies | Laravel only — no Google SDK |
Running the suite
$ composer test # Pest $ composer analyse # PHPStan level 6 $ composer format # Pint
Those three commands are the contract. A pull request that keeps them green is a pull request that will be read quickly.
Privacy, plainly
Credits
Written and maintained by Gabriele Sbaiz. The 1.x line began as a fork of JoggApp/laravel-google-translate; 2.0 is a rewrite, and the debt is gladly acknowledged. Built on Laravel and spatie/laravel-package-tools.
Licence
MIT, and it will stay MIT. Nothing is paywalled, nothing phones home, and no feature is held back for sponsors — sponsorship buys maintenance time, not features.
Upgrading from 1.x.
2.0 is a rewrite, but the calls your application already makes keep working. Two things changed on purpose; everything else is shimmed.
The two real changes
@translate($text, 'it') passed 'it' into the source slot, so the text was translated from Italian. It now reads target first, source second. If you compensated for that bug, undo it.unlessLanguageIs() has one return type. It used to return a raw string when the language already matched and an array otherwise. It now always returns a Translation; use $result->isUnchanged() to tell the cases apart.Everything else keeps working
| Method | 1.x | 2.0 |
|---|---|---|
justTranslate() | string | string — unchanged |
translate() | array | Translation, array-accessible |
translateBatch() | array | TranslationCollection |
detectLanguage() | array | DetectedLanguage, array-accessible |
detectLanguageBatch() | array | Collection |
getAvailableTranslationsFor() | array | Collection<code, name> |
sanitizeLanguageCode() | string | string — and zh-TW now works |
$result = GoogleTranslateToolkit::translate('Hello'); $result['translated_text']; // as in 1.x $result['source_language_code']; // as in 1.x $result->translatedText; // new (string) $result; // new
Configuration
Republish to get the new sections. The 1.x keys are still read, so an un-republished config keeps working exactly as it did.
| 1.x | 2.0 |
|---|---|
default_source_translation | default_source — now nullable, null means auto-detect |
default_target_translation | default_target — falls back to app.locale |
api_key | unchanged; GOOGLE_DEVELOPER_KEY still read |
Drop the SDK
$ composer remove google/cloud-translate
2.0 talks to the v2 REST endpoint through Laravel's HTTP client. If nothing else in your application needs the Google SDK, removing it takes google/cloud-core, the auth layer and the protobuf shims with it.
'fallback_to_source' => true in queued jobs and webhooks; glossary.protect for brand names; and the HasTranslations trait with translate:model instead of hand-written *_it writes.What changed, and when.
Semantic versioning. Breaking changes only in a major, and each one explained in Upgrading.
2.0.0
A full rewrite for Laravel 12 and 13. The transport is now Laravel's HTTP client; google/cloud-translate is no longer required.
Added
- Fluent, immutable builder —
from(),to(),asHtml(),withoutCache(),onFailUseSource(),preserving(),withoutGlossary(),deferred() - Translation cache with partitioned batches, namespaced flushing and
translate:cache-clear - Multi-target fan-out through one bounded
Http::pool() - Placeholder protection — ten patterns masked and restored
- Glossary — protected terms and per-locale forced wording
- Round-trip QA with
roundTrip()andtranslate:audit - Cost control —
estimate(), a daily character budget,translate:cost,translate:stats - Eloquent —
HasTranslations, theTranslatablecontract,whereTranslationMissing(),translate:model - Queue support —
TranslateAttributesJob,WarmTranslationCacheJob,warm() - Sugar —
@translateHtml, Str/Stringable/Collection macros,google_translate(),Rule::language(), theAsLanguagecast - Testing —
GoogleTranslate::fake()with six assertions - Types —
LanguageandTextFormatenums, typed exceptions, readonly data objects - Eight artisan commands, replacing none (1.x had none)
Changed
- The API key travels in an
X-goog-api-keyheader instead of the query string default_sourcemay be null, which enables Google's auto-detection — 1.x always sent a source language- Methods return typed objects that still array-access with the 1.x keys
unlessLanguageIs()always returns aTranslation
Fixed
@translate($text, 'it')passed the target language into the source slotzh-TWcould not be used as the configured default target — actype_lowercheck rejected it- No timeout, retry or cache: every call hit the API and a single blip failed the caller
- Text returned by the source fallback was written to the cache as though it were a translation, so one outage served the source language for the whole cache lifetime
1.0.0
Initial release — a thin wrapper over google/cloud-translate with translation, batch translation, detection and a Blade directive.