· 9 min read

Translations in Livewire 3: What __() Doesn't Tell You

Translating a Livewire component looks like translating any Blade view. You wrap your strings in __(), the component renders in Spanish, and you move on. Then a user clicks a button, the component re-renders, and half the interface comes back in English.

This isn't a Livewire bug. It's a consequence of how Livewire updates reach your app, and it catches almost everyone the first time. Here's what's happening and what to do about it.

Livewire updates don't run your route middleware

When Livewire re-renders a component, it doesn't hit the URL the user is looking at. It posts to /livewire/update, and that route is registered by Livewire itself with a single middleware group:

// Livewire's own route registration
Route::post('/livewire/update', ...)->middleware('web');

Only web. Not the middleware attached to the route the component was rendered from.

So if your locale middleware is applied per route or per route group — which is the common pattern — it runs on the initial page load and never again:

// This runs once, on the full page load. Never on a Livewire update.
Route::middleware(['auth', 'setlocale'])->group(function () {
    Route::get('/dashboard', Dashboard::class);
});

The first render is Spanish because setlocale ran. Every subsequent update runs without it, App::getLocale() returns your config('app.locale') default, and __() resolves against the fallback. The user sees a page that was Spanish and is now progressively turning English as they interact with it.

Fix one: put locale middleware in the web group

If your locale resolution works for every web request — reading from the session, a cookie or the authenticated user — this is the simplest and most robust fix:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        \App\Http\Middleware\SetLocale::class,
    ]);
})

Because Livewire's update route uses the web group, your middleware now runs on both the initial render and every update. Nothing else to configure.

Fix two: register it as persistent middleware

If the middleware genuinely belongs on specific routes only, tell Livewire to re-apply it. Livewire keeps a list of middleware that should persist across update requests, and by default that list is auth-related only:

// Livewire's defaults — note what isn't here
[
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
    \Illuminate\Auth\Middleware\Authenticate::class,
    \Illuminate\Auth\Middleware\Authorize::class,
    // ...
]

No locale middleware, which is exactly why this bites. Add yours in a service provider:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Livewire::addPersistentMiddleware([
        \App\Http\Middleware\SetLocale::class,
    ]);
}

Livewire then gathers the original route's middleware on each update and re-runs the ones on the persistent list.

One caveat worth understanding: persistent middleware is re-applied based on the route the component was originally rendered from. If your locale comes from a URL segment (/es/dashboard) rather than session state, verify it resolves correctly on updates — the update request's own path is /livewire/update, so anything parsing the current URL for a locale needs to read the original route instead. Session- or user-based resolution avoids the problem entirely, which is a good reason to prefer it.

Confirming which one you have

A quick way to see the problem rather than reason about it:

public function render()
{
    logger()->info('Livewire render locale: '.app()->getLocale());

    return view('livewire.dashboard');
}

Load the page, then click something. If the two log lines differ, you have this bug.

Validation messages need no special handling — but attributes do

Livewire validation resolves through the same translator as everything else, so your lang/es/validation.php applies without extra work:

public array $rules = [
    'email' => ['required', 'email'],
];

What people miss is the attribute name. :attribute gets the property name, so an error reads "El campo email es obligatorio" — with an English field name inside a Spanish sentence. Fill in the attributes array in validation.php as you would for a normal form request:

// lang/es/validation.php
'attributes' => [
    'email' => 'correo electrónico',
    'form.name' => 'nombre',
],

Note the dotted key. If your properties live inside a form object, the validation attribute path includes the prefix, and attributes has to match it exactly.

For component-specific overrides, Livewire supports the same shape as form requests — again, prefer keys over literals:

protected function messages(): array
{
    return [
        'email.unique' => __('validation.custom.email.unique'),
    ];
}

Watch where you evaluate __()

This is the subtle one. A translated string assigned to a property default, or resolved in mount(), is evaluated once and then serialized into the component state that travels to the browser and back:

class LanguagePicker extends Component
{
    // Evaluated at mount, then frozen into the payload.
    public string $heading = '';

    public function mount(): void
    {
        $this->heading = __('picker.heading');
    }
}

If the locale changes during the component's lifetime — a language switcher is the obvious case — $heading keeps the old translation, because nothing re-runs mount(). Resolve translations at render time instead:

<h2>{{ __('picker.heading') }}</h2>

Or in a computed property, which is recalculated on each request:

#[Computed]
public function heading(): string
{
    return __('picker.heading');
}

The rule of thumb: translate in the template, not in the state. Component properties should hold data; the view decides how to phrase it. That also keeps your payload smaller, since translated prose no longer round-trips on every request.

A language switcher that actually works

Putting the pieces together — the switcher has to persist the choice somewhere the middleware will read on the next request:

class LanguageSwitcher extends Component
{
    public string $locale = '';

    public function mount(): void
    {
        $this->locale = app()->getLocale();
    }

    public function updatedLocale(string $value): void
    {
        abort_unless(in_array($value, config('app.available_locales')), 400);

        session()->put('locale', $value);
        app()->setLocale($value);

        // Full page refresh so every component picks up the new locale.
        $this->redirect(request()->header('Referer') ?? '/', navigate: true);
    }
}

Two deliberate choices there. The abort_unless guard matters because the locale arrives from the client and ends up in setLocale() — validate it against a known list rather than trusting the payload. And the redirect is intentional: without it, only this component re-renders, leaving the rest of the page in the previous language. A redirect is the honest way to change something that affects every component on the page.

Your middleware then reads the session on the next request:

public function handle(Request $request, Closure $next): Response
{
    $locale = session('locale')
        ?? $request->user()?->locale
        ?? config('app.locale');

    if (in_array($locale, config('app.available_locales'))) {
        App::setLocale($locale);
        Carbon::setLocale($locale);
    }

    return $next($request);
}

Dispatched events and flash messages

Text sent to the browser through an event is rendered when you dispatch it, so it follows whatever locale is active at that moment:

public function save(): void
{
    $this->validate();
    $this->order->save();

    $this->dispatch('notify', message: __('orders.saved'));
}

That's fine within a request. The problem is a message resolved in one request and displayed after a redirect:

session()->flash('status', __('orders.saved'));

If the locale changes between the flash and the read — the language switcher is the obvious case, but so is a redirect that crosses a locale-prefixed route boundary — the message displays in the old language. Flash the key and translate on read:

session()->flash('status', 'orders.saved');
@if(session('status'))
    <div>{{ __(session('status')) }}</div>
@endif

Same principle as component properties: store the key, translate at render. If the message needs replacements, flash a small array and unpack it.

Pagination and other framework strings

Livewire's pagination views use Laravel's pagination.php lang file, so "Previous" and "Next" translate once you have that file per locale. Run php artisan lang:publish if you don't. The screen-reader labels (Go to page :page) live there too, and they're easy to leave in English because nobody sees them.

If you've published Livewire's pagination views to customise them, check you didn't hardcode the labels while you were in there — that's a common way translated pagination silently reverts.

Text that lives in JavaScript

Alpine expressions inside a Livewire component are rendered by Blade, so you can translate them at render time:

<button x-data @click="confirm(@js(__('actions.confirm_delete')))">
    {{ __('actions.delete') }}
</button>

@js() is doing real work here — it JSON-encodes the string, so apostrophes and quotes in translated copy don't break out of the attribute and produce a syntax error. That's a failure mode you'll hit the first time a French translation contains l'élément.

For strings that a standalone JS file needs, pass them in explicitly rather than reaching for a global translation bundle:

<div x-data="uploader({ error: @js(__('upload.failed')) })">

Shipping your entire lang file to the browser is a common shortcut and it's usually the wrong trade — most keys are never needed client-side, and you've turned a server-side concern into page weight.

Loading states and wire: attributes

Placeholder, title and wire:loading text are all plain HTML attributes, so they need the same treatment as any other user-facing string:

<input type="text" wire:model.live="search"
       placeholder="{{ __('search.placeholder') }}">

<span wire:loading wire:target="search">{{ __('search.loading') }}</span>

These get missed constantly, because they're attributes rather than visible text in the editor. Our Blade scanner flags translatable attributes specifically for this reason — paste a component template and it will tell you which ones are still hardcoded.

Checklist

  • Livewire updates only run the web middleware group. Put locale middleware there, or register it with Livewire::addPersistentMiddleware().
  • Prefer session- or user-based locale resolution over URL parsing, since the update request's path is /livewire/update.
  • Fill in attributes in validation.php, using the full dotted path for form-object properties.
  • Resolve __() at render time, not in mount() or property defaults.
  • A language switcher should redirect, so every component re-renders.
  • Wrap translated strings passed into JS with @js().
  • Don't forget placeholder, title and wire:loading text.

Livewire makes the interface reactive; it doesn't change where your translations live. That's still lang files, with all the usual consequences — a typo in a Spanish button label is a deploy. LangSyncer imports those files, shows which languages are behind, fills gaps with AI and publishes corrections in seconds without one. For per-language specifics like plural form counts, see the Laravel localization guides.

We use cookies to improve your experience and analyze site traffic. Cookie Policy

Cookie Preferences

Essential

Required for the site to work

Analytics

Help us improve the site

Marketing

Personalized ads and content