th/translation-log-bundle
Logs every translation lookup in your Symfony app so you can spot missing keys, unused messages and other gaps in your translation files. Browse and filter the collected data from a simple backend interface to keep your locales clean and complete.
The first thing to know, before any install step: th/translation-log-bundle is a Symfony bundle. There is no composer require path into a Laravel app, no service provider, no package:discover entry. The repository is a Symfony 2-era artifact (the *Bundle suffix, the DI extension + compiler-pass layout) and its constraints will not resolve against PHP 8.1+ / Laravel 10–11.
So treat the repo as a specification, not a dependency. Read it in this order — it is small, and the three files map 1:1 onto what you will write:
config/translation-audit.php.Then build the Laravel equivalent, which is roughly 80 lines plus tests. The minimum viable version is a decorator around the translator binding:
// app/Translation/LoggingTranslator.php
namespace App\Translation;
use Illuminate\Contracts\Translation\Translator as TranslatorContract;
use Illuminate\Support\Facades\{Cache, Log};
class LoggingTranslator implements TranslatorContract
{
public function __construct(private TranslatorContract $inner) {}
public function get($key, array $replace = [], $locale = null)
{
$locale ??= $this->inner->getLocale();
$value = $this->inner->get($key, $replace, $locale);
if ($this->looksMissing($key, $value)) {
$this->record($key, $replace, $locale);
}
return $value;
}
public function choice($key, $number, array $replace = [], $locale = null)
{
return $this->inner->choice($key, $number, $replace, $locale ?? $this->inner->getLocale());
}
public function getLocale() { return $this->inner->getLocale(); }
public function setLocale($locale) { $this->inner->setLocale($locale); }
// Loader, has(), hasForLocale(), etc. stay reachable
public function __call($method, $args) { return $this->inner->$method(...$args); }
private function looksMissing(string $key, $value): bool
{
return is_string($value) && $value === $key && ! str_contains($key, '|');
}
}
Bind it in a service provider:
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/../config/translation-audit.php', 'translation-audit');
$this->app->extend('translator', fn ($translator) => new LoggingTranslator($translator));
}
Add a dedicated log channel, then hit any page in a non-primary locale and tail storage/logs/translations.log. That is your first use case, and it is worth doing on one service, one locale, staging only, for a week — compare the misses against whatever manual QA list you have to confirm the signal is real before scoping anything larger.
Before you build any of it, spend ten minutes on feature detection: method_exists(app('translator'), 'handleMissingKeysUsing'). On newer Laravel minors the concrete translator exposes a missing-key callback, which is a far smaller hook than a decorator. Verify against your version's source rather than a blog post; the event/callback surface has moved between minors, and the decorator is the version-safe choice when in doubt.
Weekly triage digest → vendor backlog. The highest-value routine is a scheduled command that parses the log and ranks misses by (key, locale) frequency, then posts a top-N table to Slack or opens a ticket. This turns "translate everything" into a ranked queue. Ship the parser alongside the decorator or nobody will ever build it.
Unused-key pruning. The package logs every lookup, not just misses, and so should you (behind a separate, heavily-sampled flag). Run it for a week in production, then diff the logged key set against the keys statically extracted from lang/{locale}/*.php and lang/{locale}.json. Keys with zero hits across a full traffic cycle are deletion candidates. This is the concrete answer to "which strings should we keep?" and it is the same question the bundle was built for.
Release gating. A query, not a tool: zero observed misses in the top 3 locales over the last 7 days is a defensible pre-launch gate for a new market and a regression alarm for an existing one. Wire it as a step in the release checklist, and attribute each miss with release / APP_VERSION so you can tell a new regression from a long-standing gap.
Record shape. One structured line per miss, JSON formatted:
Log::channel(config('translation-audit.channel'))->warning('translation.miss', [
'key' => $key,
'locale' => $locale,
'params' => array_keys($replace), // names only — never values
'route' => optional(request()->route())->getName(),
'release' => config('app.release'),
]);
Deduplicate and sample from day one. Guard the write with Cache::add("trmiss:{$locale}:".md5($key), 1, now()->addMinutes(10)) — first occurrence per window wins, so a loop rendering the same key 500 times costs one line. On top of that, a global sample rate (random_int(1, 100) <= $rate) for high-traffic services.
Async sink. Route to a dedicated channel with a JsonFormatter and, if the volume justifies it, push to a queue/Redis stream rather than a synchronous file handler. If the sink backs up, drop misses — never add latency to a page render.
Config surface that you will actually need: enabled, channel, sample, ignore_prefixes (seed with validation., pagination., passwords., auth. — these flood the log with framework keys nobody on your team owns), max_key_length.
Complement, don't replace. Runtime logging is the only thing that catches dynamic keys; a build-time key diff in CI catches missing keys before deploy and is cheaper. Both, not either.
composer require once in a scratch container so the decision is documented, then move on.__(), @lang, validator message, and Lang::get(). Wrap the entire record path — including the Cache call and the Log call — in try { … } catch (\Throwable) {}. An observability helper must never be able to 500 a page.lang/en.json, the key is the English string, so returning the key is the intended fallback, not a miss. Gate your heuristic: only treat $value === $key as missing for PHP-group style keys (group.item, package::group.item), and skip detection entirely when the key contains spaces or matches a configured JSON locale. Otherwise your log is 90% noise and gets disabled within a month.extend('translator', …) must run before the translator is resolved. The provider must be registered after TranslationServiceProvider (it is in the default provider list, so a normal config/app.php / bootstrap/providers.php entry is fine), and you should verify end-to-end with a real route — Lang facade, __(), Blade @lang, and validator messages all resolve through the translator binding, but a package that type-hints Illuminate\Translation\Translator in a constructor and gets resolved first may hold the undecorated instance.choice(), and forward everything else via __call so getLoader(), has(), and hasForLocale() still work — dropping getLoader() breaks packages that introspect namespaces.$value === $key check. If config('app.fallback_locale') is en and the locale is fr, a French user silently sees English. Use hasForLocale() (feature-detect it) to catch that class; it is the more damaging defect and the one users never report.array_keys($replace), or hash the values — never the raw array. This should be a hard requirement, not a config option.'formatter' => Monolog\Formatter\JsonFormatter::class on the channel, your "structured" log is a text blob and every query becomes a regex. With JSON, triage is jq -r 'select(.message=="translation.miss") | .context.key' | sort | uniq -c | sort -rn | head -50.route and release attribution via middleware rather than in the translator; keep enabled togglable by env so a burst of noise is one deploy away from silence.How can I help you explore Laravel packages today?