simonecerruti / laravel-translation-audit
Audit your app for missing or unused translations.
Package info
github.com/SimoneCerruti/laravel-translation-audit
pkg:composer/simonecerruti/laravel-translation-audit
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/support: ^12.0||^13.0
- laravel/agent-detector: ^2.0
- nikic/php-parser: ^5.9
- symfony/finder: ^7.4.19||^8.1.7
- thecodingmachine/safe: ^3.4
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-phpstan: ^5.2
- pestphp/pest-plugin-rector: ^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.2.14
- phpstan/phpstan-deprecation-rules: ^2.0
- rector/rector: ^2.6
- thecodingmachine/phpstan-safe-rule: ^1.4.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Translation Audit
Find the translations your Laravel app is missing, and clean up the ones it no longer uses.
The package looks through your PHP files and Blade views for translation calls like __(), trans(), trans_choice(), @lang and Lang::get(), and gives you two commands:
translation:auditlists the keys missing a translation in one of your locales, and optionally the translations in yourlangfolder that are unused;translation:purge-unusedremoves the unused translations from yourlangfiles.
Contents
- Installation
- Commands
- Output
- Using It in CI
- Configuration
- Detecting Every Key Your App Uses
- Hooks
- Changelog
Installation
Install the package as a dev dependency:
composer require --dev simonecerruti/laravel-translation-audit
That's it, no setup needed. If you want to tweak the defaults, publish the config file:
php artisan vendor:publish --tag="laravel-translation-audit-config"
Commands
Finding Missing Translations
Run the audit:
php artisan translation:audit
You get the keys with a missing translation, grouped by the file that uses them:
app/Http/Controllers/Auth/LoginController.php
IT auth.failed
EN, IT Welcome back! Please sign in to continue with your
account.
resources/views/welcome.blade.php
IT messages.welcome
Found 3 keys with missing translations in 2 files.
Your locales are detected from the lang folder, or you can list them in the supported_locales config.
Finding Unused Translations
Add --unused to also list the translations no file uses:
php artisan translation:audit --unused
To check them on every run, set audit_unused to true in the config file.
Laravel's own translation files (auth.php, pagination.php, passwords.php and validation.php) are skipped, since the framework uses them. See How Keys Are Counted as Used for the details, and Detecting Every Key Your App Uses when a translation your app does use is reported as unused.
Removing Unused Translations
Once you know which translations are unused, you can remove them from your lang files in one go. Start with a dry run to see what would be removed, without touching any file:
php artisan translation:purge-unused --dry-run
IT
lang/it.json
Goodbye
Arrivederci
lang/it/messages.php
messages.old
Vecchio
2 unused translations would be purged.
When you're happy with the list, run it for real:
php artisan translation:purge-unused
Warning
Commit your changes first, so you can review the diff and roll back if needed. Only the lines of the unused translations are removed from the PHP translation files, along with the nested arrays left empty, leaving the rest of the source untouched, like the comments and the computed values. JSON translation files are rewritten keeping their indentation and their final newline, with an indentation of four spaces when they're on a single line.
The same files and keys skipped by the unused audit are never removed.
Some unused translations can't be removed safely, so they're left untouched, even on a dry run:
- the ones of a PHP translation file that doesn't return a literal array, like
return array_merge(...)orreturn $lines; - the ones whose key is known only by running the code, like
Status::PAID => '…', a negative key, or an item without a key following one of them or an unpacked array; - the ones of a PHP translation file whose purged source wouldn't return the same translations without the removed ones.
A warning names them for each file, along with the reason, and the summary counts them. Only the first five keys of each file are named: add -v to name them all. The JSON result lists them in a not_purged section, with the reason why each one can't be purged, grouped by locale and translation file:
{"unused":{},"not_purged":{"it":{"lang/it/legacy.php":{"legacy.old":"The file does not return a literal array."}}}}
Remove them by hand, or list them in ignore_keys.
Command Options
Most options have a matching config value, and the option always wins for a single run.
| Option | Commands | What it does |
|---|---|---|
--unused |
translation:audit |
Also lists the unused translations. |
--save, --save-path, --save-name |
translation:audit |
Saves the result to a JSON file. |
--dry-run |
translation:purge-unused |
Lists the unused translations without removing them. |
--display-format |
both | Prints the result as a list, a table or json. See Output Formats. |
--no-progress, --no-summary |
both | Hides the progress bar and the summary line. |
--for-agent |
both | Prints only the JSON result. See Running It from an AI Agent. |
--follow-links |
both | Also scans the files behind symbolic links. See Symbolic Links. |
Output
Output Formats
Pick how the result is printed with --display-format, or set it once with the display_format config:
list(default): the keys grouped by file, like the examples above.table: one row per key.json: a single line of JSON, handy for scripts.
php artisan translation:audit --display-format=json
{"missing":{"app/Http/Controllers/HomeController.php":{"auth.failed":["it"],"messages.welcome":["en","it"]}}}
When the scan skips a file, or a translation file can't be read, the JSON result, printed or saved, also has a skipped section, with the reason why each skipped file can't be scanned or read, keyed by its path:
{"missing":{},"skipped":{"resources/views/kits/edit.blade.php":"Syntax error, unexpected '<' on line 2"}}
Only the result goes to the standard output, so php artisan translation:audit --display-format=json | jq works as expected. The progress bar, the summary and the messages are printed separately and stay in your terminal.
Use --no-progress and --no-summary to hide the progress bar and the summary line.
Saving the Result
Add --save to also write the result to a JSON file:
php artisan translation:audit --save
By default the file goes to storage/app/private/translation-audits. You can change where it's saved and how it's named with --save-path and --save-name, or the matching config values. The name can include the date and a random string:
php artisan translation:audit --save --save-path=/tmp/audits --save-name="audit-{now:Y-m-d}-{random:8}"
Running It from an AI Agent
When the audit runs inside an AI agent, like Claude Code, Codex, Cursor, Gemini CLI or GitHub Copilot, it detects it and prints only the JSON result, so the agent can read it easily. You can ask for the same output yourself with --for-agent:
php artisan translation:audit --for-agent
Using It in CI
The audit fails (non-zero exit code) when it finds missing translations, or unused ones when you ask for them, so it can block a pull request. The package ships a ready-made GitHub workflow that runs it on every push to main and on every pull request:
php artisan vendor:publish --tag="laravel-translation-audit-github-workflow"
On GitLab, publish the ready-made job instead, which runs on every push to the default branch, on every merge request and on the pipelines run from the web UI:
php artisan vendor:publish --tag="laravel-translation-audit-gitlab-workflow"
It lands in .gitlab/ci/audit-translations.yml, so it never overwrites your .gitlab-ci.yml; include it from there:
include: - local: .gitlab/ci/audit-translations.yml
Configuration
A few settings are only available in the config file:
| Config | What it does |
|---|---|
scan_paths |
The files to scan, as glob patterns. By default app/**/*.php, database/**/*.php, resources/views/**/*.blade.php and routes/**/*.php. |
ignore_paths |
The files to skip, even when they match scan_paths, and the translation files never reported as unused, nor removed. By default Laravel's own translation files. Can be limited to one audit. |
supported_locales |
Your app's locales. ['auto'] detects them from the JSON files and the folders of the lang folder named like a locale, like en.json or pt_BR. |
ignore_locales |
The locales to leave out, e.g. ['en'] when your keys are the English text. Can be limited to one audit. |
ignore_keys |
The keys to leave out, for every locale or only for some, in every audit or only in one. |
translation_calls |
Your own translation functions and static methods, besides Laravel's. |
dynamic_keys |
The values of the dynamic keys, to audit each value as a key of its own. |
additional_keys |
The keys to audit even though the scan can't detect them. |
resolvers |
The custom resolvers of the keys built at runtime by your own logic. |
hooks |
The hooks run before and after the commands. |
The comments in the published config file describe every setting in detail.
Ignoring Keys
To ignore a key everywhere, list it on its own. To ignore it only for some locales, map it to them:
use TranslationAudit\Enums\AuditType; 'ignore_keys' => [ 'Hello', // ignored for every locale 'Hi' => ['en'], // ignored only for English 'payments.*', // a dynamic key, like __("payments.{$method}"), ignored by its pattern 'Bye' => AuditType::Missing, // never reported as missing, but still as unused 'Ciao' => ['en', AuditType::Unused], // never reported as unused, nor removed, in English ],
Limiting to One Audit
ignore_paths, ignore_locales and ignore_keys apply to both the missing and the unused audit. Map an entry to an AuditType case, or its value ('missing' or 'unused'), to apply it to that audit only:
use TranslationAudit\Enums\AuditType; 'ignore_paths' => [ '{lang,resources/lang}/*/validation.php' => AuditType::Unused, // the defaults, Laravel's own translation files // ... 'app/Legacy/**', // skipped by both audits 'database/seeders/**' => AuditType::Missing, // its keys aren't reported as missing, but still count as used 'app/Deprecated/**' => AuditType::Unused, // its keys are reported as missing, but don't count as used ], 'ignore_locales' => [ 'en' => AuditType::Missing, // your keys are the English text, but its unused translations are still reported and removed ],
A file the missing audit ignores is still scanned, so the translations it uses aren't removed by the purge. A file the unused audit ignores is still audited for missing translations, while the translations used only there are reported as unused, and removed. The translation files are only skipped by the unused audit, so map their patterns to AuditType::Unused, or to no audit. When you add your own ignore_paths, keep the default ones, or Laravel's translation files will be reported as unused, and removed.
Files That Can't Be Read
A file that can't be scanned, like a broken view or one using a Blade component that isn't registered, is skipped with a warning naming it, and the audit goes on with the other files. The JSON result lists it in its skipped section. The translations it uses aren't audited, so they may be reported as unused: fix the file, or add it to ignore_paths. The purge removes nothing while a file is skipped, since it would remove the translations that file uses, but a dry run still lists them.
A translation file that can't be read, like one with a syntax error or an empty PHP file that doesn't return an array, is skipped the same way, with a warning naming it, and listed in the skipped section too. Its translations aren't audited, so the keys the translator can't load in its locale aren't reported as missing, while the other locales and translation files are still audited. The purge leaves it untouched and goes on with the other translation files.
Symbolic Links
Symbolic links aren't followed by default. Pass --follow-links, or set always_follow_links to true, to scan the files behind them too. vendor and node_modules are never followed.
Detecting Every Key Your App Uses
The audit is only as good as the keys it finds. Out of the box it reads Laravel's own translation calls with a literal key; the sections below cover the keys it can't see on its own, which would otherwise be reported as unused, and removed by the purge.
How Keys Are Counted as Used
A key returning an array uses all the translations nested in it: with __('settings.languages'), every settings.languages.* translation counts as used.
A key with leading slashes uses the translations of the same PHP file, like the translator: __('/admin/form.title') uses admin/form.title.
Laravel's own translation files (auth.php, pagination.php, passwords.php and validation.php) are never reported as unused, since the framework uses them, whether your lang folder is at the project root or in resources. They are the defaults of the ignore_paths config, mapped to AuditType::Unused, and you can change them there.
Custom Translation Calls
The audit reads the keys passed to Laravel's own translation calls: __(), trans(), trans_choice(), the Lang facade, and the translator returned by trans() and app('translator'). If your app translates through its own helpers, list them in the translation_calls config, or their keys are reported as unused, and removed by the purge:
'translation_calls' => [ 't', // t('messages.welcome') 'App\Support\t', // a namespaced function App\Support\Translator::class.'::translate', // Translator::translate('messages.welcome') 'Illuminate\Support\Facades\Lang::customTranslate', // a macro on the Lang facade 'trans_for' => 1, // trans_for($locale, 'messages.welcome') ],
A function or static method listed on its own takes the key as its first argument. When the key comes later, map the call to the position of its argument, starting from 0. Name the functions and classes in full, as the facade aliases used in the Blade views, like Lang, are resolved to their class.
Only calls passing the key by position are read, not by a named argument. Calls on an instance, like $translator->translate('messages.welcome'), can't be followed by the scan: list the keys they use in additional_keys, or return them from a custom resolver. A custom Blade directive is read through the code it compiles to, so @t('messages.welcome') compiling to t('messages.welcome') only needs t in the config.
Dynamic Keys
Keys built at runtime, like __("payments.{$method}") or __('payments.'.$method), can't be checked one by one, since their values are unknown. The audit reads them as the pattern payments.* instead:
- every translation matching the pattern counts as used, so it's never reported as unused, nor removed;
- a translation matching the pattern in one locale is reported as missing in the locales without it, e.g.
payments.paypaldefined inenbut not init; - when no locale has a translation matching the pattern, the pattern itself is reported as missing.
To leave a dynamic key out, list its pattern in ignore_keys, e.g. 'payments.*'. Keys without any fixed text, like __($key), are skipped: list the translations they use in additional_keys.
When you know the values a dynamic key can take, list them in the dynamic_keys config, as a backed enum or a list. Each value is then audited as a key of its own: payments.card is reported as missing wherever it is, and a payments.* translation that isn't among the values is reported as unused, and removed by the purge.
'dynamic_keys' => [ 'payments.*' => PaymentMethod::class, // the values of the enum cases 'status.*.label' => ['active', 'suspended'], ],
A pattern has a single asterisk, in place of the dynamic part of the key.
Additional Keys
Some keys never appear in the scanned files, like those used only by your frontend, or read from the database. List them in the additional_keys config to audit them anyway:
'additional_keys' => [ 'messages.welcome', 'payments.*', // audited as a dynamic key ],
The additional keys are audited as used in config/translation-audit.php, which is where their missing translations are reported. They are never reported as unused, nor removed by the purge. A key with an asterisk in place of each dynamic part, like payments.*, is audited as a dynamic key, expanded by the dynamic_keys config when it lists its values. ignore_keys still applies to them.
Custom Resolvers
Some keys are built by logic that a scan can't follow, and that a list of values can't describe, like a label key derived from the enum case:
trait HasLabel { public function label(): string { return __('orders.status.'.str($this->value)->replace('_', '-')); } }
For these, write a resolver: a class implementing TranslationAudit\Contracts\TranslationKeyResolver, which returns the keys the app uses at runtime, and the dynamic keys they replace:
use TranslationAudit\Contracts\TranslationKeyResolver; class OrderStatusLabelKeys implements TranslationKeyResolver { public function resolve(): iterable { foreach (OrderStatus::cases() as $case) { yield 'orders.status.'.str($case->value)->replace('_', '-'); } } public function covers(): array { return ['app/Enums/Concerns/HasLabel.php' => 'orders.status.*']; } }
Then list it in the resolvers config:
'resolvers' => [ App\Translations\OrderStatusLabelKeys::class, ],
The resolvers are resolved from the container, so their constructor can take any dependency. The keys they return are audited as used in a file named after the resolver class, which is where their missing translations are reported. A resolver returns the keys, not their translations.
covers() maps a glob pattern of the files, relative to the project root, to the pattern, or the list of patterns, of the dynamic keys the resolver replaces. Those dynamic keys are dropped, so a translation matching them but not among the resolved keys is reported as unused, and removed by the purge. The other keys of the covered files are still audited. Return [] when the resolver replaces no dynamic key.
Keys without any fixed text, like __($key), are skipped by the scan, so a resolver returning the keys they use needs no covers().
Hooks
To run your own code around a command, like pulling the translations from your translation service before the audit, or notifying your team of its result, write a hook: a class with a before method, called before the files are scanned, an after method, called once the result is printed, or both:
use TranslationAudit\Console\Commands\AuditCommand; use TranslationAudit\Results\Contracts\Result; class NotifyTeam { public function before(AuditCommand $command): void { // ... } public function after(Result $result, Slack $slack): void { if (! $result->isClean()) { $slack->send('Some translations need attention.'); } } }
Then list it in the hooks config. A listed hook runs on every command, a hook mapped to a command class, or a list of command classes, only on those:
use TranslationAudit\Console\Commands\AuditTranslations; use TranslationAudit\Console\Commands\PurgeUnusedTranslations; 'hooks' => [ App\Translations\PullTranslations::class, // every command App\Translations\NotifyTeam::class => AuditTranslations::class, // only translation:audit App\Translations\CommitLangFiles::class => [PurgeUnusedTranslations::class], ],
The hooks run in the order of the config. Each one is resolved from the container once per run, so its before and after methods share the instance. The methods are called through the container: the command is passed to the $command argument, or to an argument typed as the command class, the result to the $result argument of after, or to an argument typed as the result class, and any other dependency is injected.
A hook throwing an exception fails the command with its message. A failing before hook stops the command before the scan, while a failing after hook runs once the command is done, so the purge has already removed the unused translations.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Thank you for considering contributing to Laravel Translation Audit! Please review our contributing guide to get started.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
Laravel Translation Audit is open-sourced software licensed under the MIT license.