laravel maintained by i18n-keyless
i18n-keyless for Laravel
Keyless translations for Laravel. Your existing __('Welcome to our app') calls (Laravel's
JSON "keyless" mode) resolve through the i18n-keyless API: a missing string is translated by
AI once, for every language, and served from your cache from then on. No more lang/*.json
files to maintain by hand.
Quick start
composer require i18n-keyless/laravel
# .env
I18N_KEYLESS_API_KEY=your-key # https://i18n-keyless.com/#get-api-key
I18N_KEYLESS_LANGUAGES=en,fr,es # every language your app serves
Done. __('Welcome to our app') returns the translation for App::getLocale().
That is the whole integration: one composer require, two .env lines, zero code change.
Laravel 11, 12 and 13, PHP >= 8.2.
How it works
- Nothing happens for the primary language: the source string is the translation.
- The first
__()call that misses in another locale loads that locale's dictionary from your cache. On the very first request ever for that language, it is fetched from the API (GET /translate/{lang}) and stored. Then it is injected into Laravel's translator with Laravel's translator through the missing-key handler (Lang::handleMissingKeysUsing), so that every served string can be counted; withI18N_KEYLESS_USAGE=falseit is injected withLang::addLines()for the JSON group*instead. Laravel's own loader stays in place either way: PHP array files, vendor files andlang/{locale}.jsonkeep working, and a line fromlang/{locale}.jsonwins over the API's. - A string the dictionary does not have is returned as-is (the source text, with
:nameplaceholders replaced by Laravel), and recorded. The request is never blocked by a miss. - After the response is sent (
app()->terminating), the recorded strings are sent toPOST /translatewith the configuredlanguages(plus the primary), deduplicated by key and context, at most 30 in flight at once, and the answers are merged into the cache. The next request has them. WithoutI18N_KEYLESS_LANGUAGESnothing is sent: the API stores the list it receives as the project's languages, so an incomplete list would damage every other client on the same key. One warning is logged per process. - A dictionary older than
cache.ttlis still served, then revalidated after the response with itsETag(If-None-Match): an unchanged dictionary costs a bodyless304. - Usage analytics, like the node SDK: the date each string was last served is recorded and
the cumulative map is sent to
POST /translate/last-used-translationsafter the response, at most once every 10 s across all your processes (a lock in the cache). It feeds the dashboard's "last used" column so unused strings can be pruned. A failed POST never affects the response; the map waits in the cache for a later request.I18N_KEYLESS_USAGE=falseswitches it off.
Every API call has a 10 s timeout and is retried twice with backoff (500 ms, 1500 ms) on a
network error, a timeout, a 429 or a 5xx. Any other 4xx is not retried. Nothing ever
throws: on failure the page shows the source text, and the failure is remembered for 60 s so
the API is not hammered.
Configuration
php artisan vendor:publish --tag=i18n-keyless-config writes config/i18n-keyless.php.
Every value has an .env counterpart:
| Config key | Env | Default | What it is |
|---|---|---|---|
enabled |
I18N_KEYLESS_ENABLED |
true |
false switches the package off: Laravel behaves as without it. |
api_key |
I18N_KEYLESS_API_KEY |
none | Your project's key. Without it the package stays inactive. |
api_url |
I18N_KEYLESS_API_URL |
https://api.i18n-keyless.com |
The official service, or your own backend / proxy that speaks the same wire format. |
primary |
I18N_KEYLESS_PRIMARY_LANG |
config('app.locale') |
The language your source strings are written in (en, fr, pt-BR, zh-Hans...). A Laravel locale (pt_BR) is accepted. |
languages |
I18N_KEYLESS_LANGUAGES |
empty | Required for translation. Every language your app serves, comma separated (en,fr,es). A new string is translated into all of them at once, and the API stores the list as the project's languages. When empty, missing strings are served as their source text and never sent; one warning is logged. |
namespace |
I18N_KEYLESS_NAMESPACE |
default |
The i18n-keyless namespace of the __() strings. |
cache.store |
I18N_KEYLESS_CACHE_STORE |
default store | Any store from config/cache.php (file, redis, database, memcached, dynamodb...). |
cache.ttl |
I18N_KEYLESS_CACHE_TTL |
3600 |
Seconds a dictionary is served without asking the API. After that it is revalidated with its ETag after the response. |
cache.prefix |
i18n-keyless |
Prefix of every cache key the package writes. | |
timeout |
10 |
HTTP timeout in seconds, per attempt. | |
retry |
[500, 1500] |
Backoff in milliseconds between retries: two entries, two retries. | |
concurrency |
30 |
Maximum POST /translate requests in flight at once. |
|
usage |
I18N_KEYLESS_USAGE |
true |
Usage analytics: the date each string was last served, POSTed at most once every 10 s. false disables it and injects the dictionary with Lang::addLines(). |
queue |
I18N_KEYLESS_QUEUE |
none | A queue name: the misses of a request are dispatched as one TranslateMissingKeys job instead of being sent in terminating. |
I18N_KEYLESS_LANGUAGES is the list the API translates a new string into, and the list it
stores as your project's languages (it replaces the previous one, like the supported list
the JavaScript SDKs send). A locale that is not in the list is served, but never translated.
Locales
Laravel locales are mapped onto the 48 i18n-keyless codes: fr is fr, pt_BR is pt-BR,
zh_CN is zh-Hans, zh_TW is zh-Hant, en_US is en, fr_FR is fr, es-419 is
es-MX. A locale no code matches (xx) is left alone: __() returns the source text and
nothing is sent. The full list is I18nKeyless\Laravel\Locale::AVAILABLE_LANGS.
Context: one string, two meanings
8 heures is "8 hours" in a duration and "8 AM" on a clock. __() has one slot per string,
so use the i18nk() helper with a context:
i18nk('8 heures', context: 'duration') // "8 hours"
i18nk('8 heures', context: 'clock time') // "8 AM"
i18nk('Bienvenue :name', ['name' => $user->name], context: 'greeting')
The string is stored as key__context, exactly like the JavaScript SDKs, so the dashboard
and every other SDK see the same entry. i18nk() without a context is __().
Placeholders are Laravel's job: write :name in the source string, pass ['name' => ...]
as the second argument, and Laravel replaces them after the translation. The SDKs' replace
option has no equivalent here because Laravel already does it.
i18nk() also takes locale: and namespace: named arguments.
Cache
Dictionaries live in Laravel's cache under i18n-keyless:{key-hash}:dict:{namespace}:{lang},
stored without expiry. cache.ttl is not their lifetime: it is how long they are served
without revalidation, so a stale dictionary is never thrown away before the API has confirmed
a newer one. To force a refetch, php artisan cache:clear (or forget those keys).
Under Octane, or in a queue worker, the process keeps the injected lines between requests;
they are refreshed when a revalidation brings a new dictionary, and after each POST /translate. A dashboard edit reaches a running process at the next revalidation, at most
cache.ttl seconds later.
Limitations
- Plurals.
trans_choice('Il y a une pomme|Il y a :count pommes', $n)sends the whole pipe string as one source string, and the AI translates the pipe as text. Prefer onei18nk()per plural form with acontext(context: 'one',context: 'other'), or keep plurals in PHP array files (lang/{locale}/messages.php), which Laravel keeps loading. - Keys with a dot (
__('Bonjour. Ça va ?')) are served through the missing-key handler, notaddLines(which splits on dots). Same result, one extra function call. - One
handleMissingKeysUsingcallback. The package registers it. If your application registers its own (to log missing keys, say), call the package from it:app(\I18nKeyless\Laravel\KeylessTranslator::class)->handleMissingKey($key, $replace, $locale, $fallback). - Same string in two namespaces. The lines of every namespace are injected into Laravel's
single JSON group per locale; the last one injected wins for
__(). - Usage analytics count the strings served by this package only. A string served from
lang/{locale}.jsonnever reaches it, so it is not counted. - Blade compiled views are not affected:
__()runs at render time, not compile time.
Self-hosted backend or proxy
Point I18N_KEYLESS_API_URL at a server that speaks the three-route wire format
(GET /translate/{lang}, POST /translate, POST /translate/last-used-translations).
See https://docs.i18n-keyless.com/docs/guides/proxy-mode. Every request carries
Authorization: Bearer <api_key>, Version: 3.3.0 (the wire dialect: v3 language codes) and
sdk: laravel (a server label, counted like node: by its connection, not by a device id). The usage
route POST /translate/last-used-translations is called too.
Publishing to Packagist
This directory lives inside the i18n-keyless monorepo and is not an npm workspace.
Packagist needs a repository whose root is this directory. Either split it:
git subtree split --prefix=ports/laravel -b laravel-package
git push git@github.com:arnaudambro/i18n-keyless-laravel.git laravel-package:main
or keep a mirror repository in sync from CI, then submit that repository on Packagist as
i18n-keyless/laravel. Tag releases on the mirror; the version number follows the SDKs
(3.x).
Development
cd ports/laravel
composer install
vendor/bin/phpunit
composer test:coverage # vendor/bin/phpunit --coverage-text
The coverage report needs a driver: pcov (fast) or Xdebug with XDEBUG_MODE=coverage.
With Homebrew's PHP, pecl install pcov builds it; if the build cannot find pcre2.h,
pass the header path (CPPFLAGS=-I$(brew --prefix pcre2)/include pecl install pcov), and
if the install step cannot create the extension directory, create it first
(mkdir -p $(php -i | sed -n 's/^extension_dir => \([^ ]*\).*/\1/p')). php -m | grep pcov
confirms it. The <coverage> block of phpunit.xml limits the report to src/; every file
there is kept at 95 % of its lines or more.
Tests run on Orchestra Testbench with Http::fake(): no network, no key.
tests/Conformance/VectorsTest.php replays the monorepo's shared protocol vectors
(conformance/vectors/*.json): language codes, locale resolution, storage key, namespace
resolution, retry decisions, backoff scenarios, dictionary and translate requests and
responses. The test is skipped when the vectors directory is absent (standalone checkout).
One deliberate difference from the JavaScript SDKs: misses are deduplicated by key AND
context (the SDK queue ignores the context), so two contexts of one string on one page are
both translated after that page. Usage analytics follow the node SDK (sdk: laravel is a server label with the node rules).