What is Translation Key?
A translation key is the identifier your code uses to look up a translated string. Instead of embedding copy directly, the code references a key like auth.failed, and the i18n layer resolves it against the active locale. Keys decouple code from copy: writers and translators can change text without touching source files.
There are two common naming styles. Structured keys (dashboard.stats.title) are stable and unambiguous but meaningless without context. Natural-language keys use the source text itself as the key, which Laravel supports through JSON translation files, and keep templates readable at the cost of churn when source copy changes. Laravel supports both: PHP array files for structured keys, a JSON file per locale for natural-language keys.
Whichever style you pick, consistency matters more than the choice. Mixed conventions make coverage hard to measure and duplicates hard to spot, especially once a project passes a few hundred keys.
// Structured key (lang/en/auth.php)
__('auth.failed');
// Natural-language key (lang/es.json)
// { "These credentials do not match our records.": "Estas credenciales no coinciden..." }
__('These credentials do not match our records.');
How Laravel resolves a translation key
When you call __('billing.invoice.ready'), Laravel first looks for the whole string as a key in the active locale's JSON file, lang/fr.json for French. Only if that fails does it split the key on dots: the first segment names the PHP file (lang/fr/billing.php) and the rest is a path into its nested array. Package translations add a namespace and a double colon in front, as in package::file.key.
If nothing matches in the active locale or the fallback locale, the key itself is returned. That is deliberate, because a raw billing.invoice.paid on screen is easy to spot. It also means a missing structured key is visible, while a missing natural-language key is not, since the key is already readable English. One more behaviour surprises people: a key that points at an array instead of a single line, such as billing.invoice, returns the whole array, which throws an error as soon as Blade tries to echo it.
// lang/en/billing.php
return [
'invoice' => [
'ready' => 'Your invoice is ready',
'overdue' => 'Invoice :number is overdue',
],
];
__('billing.invoice.ready'); // "Your invoice is ready"
__('billing.invoice.overdue', ['number' => 'A-17']); // "Invoice A-17 is overdue"
__('billing.invoice.paid'); // "billing.invoice.paid" (missing)
__('billing.invoice'); // an array, not a string
Lang::has('billing.invoice.paid'); // false
Naming conventions that hold up
Group keys by where they appear, not by what kind of word they are. checkout.summary.total survives refactors and tells a translator where the string lives; a shared words.php file invites reusing one translation in places that need different ones.
Name the purpose, not the current wording. auth.login.submit stays correct when the button changes from Log in to Sign in, while auth.login.log_in_button becomes misleading the first time someone edits the copy. Use one casing style (snake_case, as Laravel's own lang files do), keep nesting to two or three levels, and give each place in the UI its own key even when two strings are identical in English today. Two Save buttons that share a key will share a translation, and some languages use different verbs for saving a file and saving a setting.
Natural-language JSON keys trade these properties for readable templates. Editing the English text changes the key, which orphans every existing translation of it, so JSON keys suit short, stable strings best.
Common mistakes
Building keys at runtime is the most expensive one. __("orders.status.{$order->status}") works, but no extractor, IDE or reviewer can tell which keys exist, so missing and unused keys go unnoticed. Spell out each key, in a match expression or an enum method, so every key appears literally in the code.
Concatenating translated fragments is the second. Joining __('cart.you_have'), a number and __('cart.items') bakes English word order into the code. Use one key with placeholders, :count in Laravel's syntax, and trans_choice() when the count changes the wording.
The third is leaving dead keys behind when a feature is removed. They cost translator time for every new language and make coverage numbers look worse than the real state of the product.
// Hidden from static analysis: the full key never appears in the code
__("orders.status.{$order->status}");
// Every key is a literal that tools can find
$label = match ($order->status) {
'paid' => __('orders.status.paid'),
'refunded' => __('orders.status.refunded'),
default => __('orders.status.pending'),
};
How to check your keys
Lang::has() tells you whether a key resolves, counting the fallback locale; Lang::hasForLocale() checks a single locale, which is what you want when measuring coverage. For runtime visibility, Lang::handleMissingKeysUsing() registers a callback that receives every key Laravel could not resolve, so you can log it instead of waiting for a user's screenshot.
On a deployed site, raw keys leaking into the HTML are the visible symptom. Scanning rendered pages for dot-notation strings such as billing.invoice.paid catches the keys your tests never exercised.