All articles
10 min read

Laravel Localization with AI: PHP Arrays, JSON and a Workflow That Scales

How Laravel’s two translation file formats work, when to use each, and how to add AI-assisted translation without losing review control.

LaravelPHPAI translation

Laravel ships with two parallel translation systems, and most confusion about localization in a Laravel app traces back to a team using both without deciding which one owns what. This article explains both, covers the parts of the API people get wrong most often – pluralization ranges and placeholder capitalisation – and then shows how to layer AI translation on top without ending up with machine output in production.

Everything below targets Laravel 9 and later, where translation files live in a top-level lang/ directory rather than inside resources/.

The two translation systems

PHP array files with dot notation

Files live at lang/{locale}/{group}.php and return a plain array. Nested arrays are addressed with dots.

// lang/en/messages.php
return [
    'welcome' => 'Welcome to our shop',
    'cart' => [
        'empty' => 'Your cart is empty',
        'checkout' => 'Proceed to checkout',
    ],
];
__('messages.welcome');          // "Welcome to our shop"
__('messages.cart.empty');       // "Your cart is empty"

The lookup key is group.path.to.key, where group is the filename. If the key does not resolve, Laravel returns the key string itself, so a typo shows up in the UI as a literal messages.cart.emtpy. Ugly, but at least loud.

JSON files keyed by the source string

Files live at lang/{locale}.json – one file per locale, keyed by the English text.

{
    "Your cart is empty": "Votre panier est vide",
    "Proceed to checkout": "Passer la commande"
}
__('Your cart is empty');   // "Votre panier est vide" when the locale is fr

This is fast to write: you type English into Blade and add translations later. The trap is that the source string is the key. Change "Your cart is empty" to "Your cart is empty." and every translation for that string silently falls back to English, because the missing-key fallback is "return the key". Nothing errors. Nothing logs, unless you ask it to:

// AppServiceProvider::boot()
use Illuminate\Support\Facades\Lang;

if (! app()->isProduction()) {
    Lang::handleMissingKeysUsing(function (string $key, array $replacements, string $locale) {
        logger()->warning('Missing translation', compact('key', 'locale'));
    });
}

Choosing one, and why mixing hurts

Both systems work; the cost is in the seam between them. A translator opening your project sees two different key spaces, tooling has to export two shapes, and "where does this string live?" becomes a question every developer asks weekly. Worse, the same sentence often ends up in both files with two different translations.

A workable rule:

UseFor
PHP array filesAnything structural or reused: navigation, buttons, validation, emails, domain vocabulary
JSON filesLong-form prose in a small app where the English text is genuinely stable

If your app is bigger than a few dozen strings, pick PHP array files for everything and treat JSON as the interchange format for tooling, not for runtime lookup. Framework internals nudge you the same way: validation, pagination and password-reset messages are all PHP array files.

Placeholders, and the capitalisation behaviour

Placeholders are prefixed with a colon and replaced by the second argument.

// lang/en/messages.php
return [
    'greeting' => 'Hello, :name – you have :count new orders',
];
__('messages.greeting', ['name' => 'ada', 'count' => 3]);
// "Hello, ada – you have 3 new orders"

The genuinely useful detail: Laravel inspects the case of the placeholder token and applies the same casing to the replacement value.

// 'welcome' => 'Welcome, :name'   → "Welcome, ada"
// 'welcome' => 'Welcome, :Name'   → "Welcome, Ada"
// 'welcome' => 'Welcome, :NAME'   → "Welcome, ADA"

So :Name gives you ucfirst and :NAME gives you uppercase, without touching the calling code. This is a gift for languages where a noun must be capitalised mid-sentence, and a hazard for AI translation: a machine translator that "helpfully" normalises :Name to :name, or inserts a space after the colon, breaks replacement silently. More on that below.

Pluralization with trans_choice

Plural forms are separated by pipes, and trans_choice() picks a branch based on a count.

// lang/en/messages.php
return [
    'apples' => 'There is one apple|There are many apples',
];
trans_choice('messages.apples', 1);   // "There is one apple"
trans_choice('messages.apples', 10);  // "There are many apples"

Two forms are rarely enough. Explicit counts and ranges give you full control:

return [
    'orders' => '{0} No orders yet|{1} One order|[2,9] A few orders|[10,*] :count orders',
];
trans_choice('messages.orders', 0);   // "No orders yet"
trans_choice('messages.orders', 4);   // "A few orders"
trans_choice('messages.orders', 42);  // "42 orders"

{0} and {1} match exact values; [2,9] is an inclusive range; * is an open upper bound. The :count placeholder is filled automatically from the number you pass, and you can pass more replacements as a third argument.

The important part for translation: the number of plural branches is a property of the target language, not of the source string. English needs two, French broadly two, Russian and Polish three, and Arabic distinguishes as many as six categories. A translation pipeline that assumes "one pipe in, one pipe out" will produce grammatically broken plurals. Whoever reviews the target language has to be allowed to add branches.

Setting the locale

The locale is a runtime value. config('app.locale') is the default and config('app.fallback_locale') is what Laravel falls back to when a key is missing in the active locale.

For a URL-segment scheme (/fr/checkout), a small middleware is enough:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;

class SetLocale
{
    protected array $supported = ['en', 'fr', 'de'];

    public function handle(Request $request, Closure $next)
    {
        $locale = $request->route('locale')
            ?? $request->session()->get('locale')
            ?? $request->getPreferredLanguage($this->supported)
            ?? config('app.locale');

        if (! in_array($locale, $this->supported, true)) {
            $locale = config('app.locale');
        }

        App::setLocale($locale);
        $request->session()->put('locale', $locale);

        return $next($request);
    }
}
// routes/web.php
Route::prefix('{locale}')
    ->whereIn('locale', ['en', 'fr', 'de'])
    ->middleware(SetLocale::class)
    ->group(function () {
        Route::get('/checkout', CheckoutController::class);
    });

Validate against an allow-list, as above – passing raw user input to App::setLocale() is how you end up loading unexpected files. For queued jobs and mail, remember the locale does not travel with the job automatically; Laravel's Mailable has a locale() method, and jobs should carry the locale explicitly.

Vendor and framework language files

Framework messages (validation, passwords, pagination) are not in your repo by default. Publish them when you want to edit or translate them:

php artisan lang:publish

That writes lang/en/validation.php and friends, which you then translate per locale. Packages follow a different convention: php artisan vendor:publish places their files under lang/vendor/{package}/{locale}/, and anything you put there overrides the package's own copy. Include lang/vendor/ in whatever you feed your translation tooling, or half your admin panel stays English.

Adding AI translation without losing control

The mechanical part of localization – taking 40 new English strings and producing first-pass French, German and Spanish – is what machines are good at. The judgement part is not. A workflow that respects the split looks like this.

1. Extract, and fail the build on missing keys

Laravel has no built-in string extractor, but a scanner covers most of the ground:

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Lang;
use Symfony\Component\Finder\Finder;

class LangScan extends Command
{
    protected $signature = 'lang:scan';
    protected $description = 'Report translation keys used in code but absent from lang/en';

    public function handle(): int
    {
        $files = Finder::create()
            ->in([app_path(), resource_path('views')])
            ->name('*.php');

        $keys = [];

        foreach ($files as $file) {
            preg_match_all(
                '/(?:__|trans|trans_choice|@lang)\(\s*[\'"]([^\'"]+)[\'"]/',
                $file->getContents(),
                $matches
            );

            foreach ($matches[1] as $key) {
                $keys[$key] = true;
            }
        }

        $missing = [];

        foreach (array_keys($keys) as $key) {
            // Third argument disables fallback, so this asks "is it really in en?"
            // and not "does some fallback locale happen to cover it?".
            if (Lang::has($key, 'en', false)) {
                continue;
            }

            $missing[] = $key;
        }

        foreach ($missing as $key) {
            $this->warn("missing: {$key}");
        }

        return $missing === [] ? self::SUCCESS : self::FAILURE;
    }
}

Two caveats. The regex only catches literal arguments – dynamic keys such as __($key) are invisible to it, which is one more reason to prefer literals at call sites. And the scanner cannot tell a group key from a JSON key by itself: messages.cart.empty is a dot-notation lookup, while Your cart is empty. is a whole sentence that merely ends in a period. If you have committed to PHP array files, that ambiguity disappears, which is another argument for picking one system.

2. Push a delta, not the whole file

Re-translating everything on every run is expensive and destroys human edits. Push the source language, let the platform diff it, and translate only what is new or changed.

In Translatize that unit of work is a branch: create one per feature, auto-translate the missing values there, and have a human review them before it reaches main. The merge strategy decides what happens to keys that exist on both sides. overwrite is a non-destructive union – keys that live only on main survive the merge – but on a genuine conflict the source branch wins, so it is the right choice when the branch is the more recent truth and the wrong one if main has been edited underneath you. keep-newer resolves each conflict by recency instead, and manual hands the conflicting keys back to a person. Merging to main is permission-guarded: only admins and owners can do it, while any developer can create a branch to work in. The AI localization workflow post covers the branching model in more depth.

Two practical notes for Laravel specifically. First, AI auto-translation is a Professional and Agency plan feature, metered by characters per month – on Free or Starter the same branching workflow still applies, you just supply the translations yourself. Second, PHP array files are not one of the platform's nine import/export formats, so JSON (nested or flat) is your interchange point in both directions; step 3 below converts back.

A CI step that keeps source strings flowing:

name: translations
on:
  pull_request:
    paths: ['lang/**', 'app/**', 'resources/views/**']

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install --no-interaction --prefer-dist
      - run: php artisan lang:scan
      - run: npx @translatize/cli push
        env:
          TRANSLATIZE_API_TOKEN: ${{ secrets.TRANSLATIZE_API_TOKEN }}

The CLI reads its project id and its file pattern from a config file written by npx @translatize/cli init, which for this layout points at the per-locale JSON files. push only sends new and changed local keys and never deletes remote ones; status --fail-on-missing is the companion command if you want the pipeline to go red when a language is incomplete. No branch argument appears in the step because integration tokens are prefixed mcni_ and are bound to a single project and a single branch – the commands operate on that branch, and a CI token cannot touch main by accident. Current flag names are in the docs.

3. Review before merge, then convert back to PHP

Pull the finished locales as JSON and convert them to PHP array files as part of the same command, so the committed artefacts stay in Laravel's native shape:

$locale = 'fr';
$data = json_decode(file_get_contents(lang_path("{$locale}.json")), true);

foreach ($data as $group => $lines) {
    file_put_contents(
        lang_path("{$locale}/{$group}.php"),
        "<?php\n\nreturn " . var_export($lines, true) . ";\n"
    );
}

Review statuses – draft, review, approved, rejected – exist so a machine pass lands as draft and a human moves it to approved. Gate the merge to main on approval rather than on the pipeline being green. Because every member of the project is notified over WebSocket when a label is added, edited or merged, a reviewer does not have to poll the branch to notice that a batch is waiting.

What not to machine-translate

Some categories are worth excluding from automatic passes entirely:

  • Validation messages. They are dense with :attribute, :min, :other tokens. Machine engines translate, reorder, or space-pad those tokens, and the result fails silently – the placeholder simply is not replaced. If you do auto-translate them, diff the placeholder set before and after and reject any string where it changed.
  • Legal and financial copy. Terms, privacy text, refund policy, tax wording. A mistranslation here is a liability, not a bug.
  • Plural strings, unless your tooling and reviewer both understand that the target language may need more branches than the source.
  • Brand and product names, which should be pinned in a glossary and left alone.

Everything else – UI labels, empty states, tooltips, transactional email bodies – is a reasonable candidate for a first machine pass followed by human review.

Where Translatize fits

Laravel gives you the runtime half – __(), trans_choice(), locale resolution. Where the strings live between the repository and the people writing them is left to you.

Translatize imports and exports per-locale JSON directly, nested or flat; PHP array files need the small conversion shown above, because PHP is not one of the nine supported formats (JSON, YAML, CSV, XLIFF 1.2, gettext PO, Flutter ARB, .NET RESX, Android XML, iOS .strings). Each feature branch holds its strings in isolation until an admin or owner merges to main, so a translator can own the branch carrying validation.php or refund copy without touching production strings. Draft, review, approved and rejected statuses are the gate this article argues for, and a label's description and character-limit fields are where you record "leave :attribute alone" for whoever reviews it.

Wrapping up

Pick one file format and stay with it; PHP array files with dot notation age better in anything non-trivial. Learn the two API details that bite – range syntax in trans_choice and the casing behaviour of :Name – because both survive into your translated files. Then automate the boring half: scan for missing keys in CI, translate only the delta, keep machine output in a branch behind a review status, and keep validation messages and legal text on the human side of the line.

If you are localizing other parts of the same product, the YAML and JSON guides cover the formats you will meet on the frontend.

Keep reading