Laravel makes you choose between readable templates and stable translation keys

This is me

This is me

Laravel's __() helper makes you pick a side, and both sides cost you something.

Pass it a key and your templates fill with dotted identifiers. __('auth.failed') tells you nothing at the call site; to know what the user reads you open lang/en.json and search. Multiply that across a few hundred strings and reviewing a Blade file becomes an exercise in cross-referencing.

Pass it the text and you get readable templates, but the English is the key. __('These credentials do not match our records.') works until someone fixes the missing comma. Then the key changes, every translation stored under the old sentence is orphaned, and nothing errors: Spanish quietly falls back to English. The failure is invisible, which is the worst property a failure can have.

Neither is a bad design. They are the same design with the trade-off pointed in different directions, and __() can only take one argument that matters.

The first decision: two arguments, not one

The whole package starts from refusing to choose:

text('auth.failed', 'These credentials do not match our records.')

The key is stable and the sentence is at the call site. Reword the English as much as you like and the key does not move, so no translation is orphaned. Read the template and you know what it says, so nothing needs cross-referencing.

That is barely an idea. It only becomes one because of what the second argument makes possible.

The second decision: scan source, do not catch misses

Once a readable default lives next to every key, your source code contains a complete list of strings and their English text. You do not need to observe the app to find them.

That rules out the obvious alternative, which is to hook missing-translation events at runtime and collect them as users hit them. It sounds cheaper, and it is worse in every way that matters:

  • Coverage depends on traffic. A string on a page nobody visited this month does not exist as far as your collector knows.
  • It cannot tell "missing" from "changed". A key whose English was rewritten still resolves, so nothing fires, and Spanish keeps the stale sentence forever.
  • It puts translation work on the request path, or behind a queue you now have to operate.

A static scan has none of those. It reads every text() and @text in the codebase, diffs against what each language file already has, and translates the gap. Coverage is whatever is in the repository, which is the correct answer. And because the scan knows the current English for each key, it can also detect that the source text changed and retranslate just those, which is what --resync does. A runtime collector structurally cannot do that.

The third decision: translation is an interface

The scan needs something to actually translate with, and this is where I deliberately did not decide for you.

Hardcoding one provider would have been shorter. But translation quality is not a solved problem and the right answer is different per project: some need a glossary, some need a specific model, some cannot send strings to a third party at all and need a human review queue. So the translator is an interface with implementations for OpenAI, Google and Claude shipped in the box, and make:translator scaffolds your own.

The base class exists for one reason worth explaining. The naive implementation of "translate these 400 strings" is 400 API calls, which is slow and expensive. So Translator hands you batchTranslate, which splits the set into batches capped by both character count and item count, and any implementation that overrides translateMany translates a whole batch in one request. One round trip instead of a hundred, without every translator author having to think about batching.

The Claude translator goes one step further and uses prompt caching, so repeated batches inside a single scan reuse the cached instructions rather than resending them.

Where it ended up

One of my apps runs several hundred strings through @text, in English, Spanish and Chinese. Adding a language is a config line and a re-scan. Nobody has hand-edited a translation file in months, and no reword has silently orphaned a translation, because rewording no longer touches the key.

That is the whole thesis: keys and readable text are not in tension, they were just competing for the same argument slot.

Full usage, configuration and the scan options are on the Laratext page. Source on GitHub, package on Packagist.