Localized Emails and Notifications in Laravel: The Queue Is Where It Breaks
The report always sounds the same: the app is fully translated, the user browses in Spanish, places an order, and the confirmation email arrives in English. Nothing is broken in the templates. The problem is that the email was rendered somewhere the request locale never reached.
Emails are the most commonly missed surface in Laravel localization, for a structural reason: they're the only user-facing output that's usually generated outside the request that triggered it.
Why the queue loses the locale
App::setLocale() sets the locale for the current process. A queued job runs in a different process — a worker that started hours ago, bootstrapped with config('app.locale'), and has no idea a Spanish-speaking user exists:
// In the request: locale is 'es'
Mail::to($user)->queue(new OrderConfirmation($order));
// In the worker: locale is whatever config/app.php says. Usually 'en'.
The job payload carries your Mailable and its constructor arguments. It does not carry ambient application state, and the locale is ambient state.
Notifications: mostly handled for you
Laravel has a built-in mechanism, and it's the one to reach for first. Implement HasLocalePreference on your user model:
use Illuminate\Contracts\Translation\HasLocalePreference;
class User extends Authenticatable implements HasLocalePreference
{
public function preferredLocale(): ?string
{
return $this->locale;
}
}
Laravel checks for this interface when sending notifications and renders in that locale automatically — including queued ones. No changes at call sites.
This is the right default because it follows the recipient, not the sender. If an admin browsing in English triggers a notification to a Spanish-speaking customer, the customer gets Spanish. That's almost always what you want, and it's a case explicit locale-passing gets wrong.
You can also force one explicitly:
$user->notify((new InvoiceReady($invoice))->locale('de'));
Mailables need the locale passed
Mail::to(...)->send(new SomeMailable) does not consult HasLocalePreference. You have two options.
Set it on the mailable:
Mail::to($user)
->locale($user->locale ?? app()->getLocale())
->queue(new OrderConfirmation($order));
Laravel stores that locale in the queued payload and applies it when rendering.
Or use the Localizable trait in a job, which is what you want when the job does more than send one email:
use Illuminate\Support\Traits\Localizable;
final class ProcessOrder implements ShouldQueue
{
use Queueable, Localizable;
public function __construct(
private readonly Order $order,
private readonly string $locale,
) {}
public function handle(): void
{
$this->withLocale($this->locale, function () {
Mail::to($this->order->email)->send(new OrderConfirmation($this->order));
$this->order->update([
'status_label' => __("orders.status.{$this->order->status}"),
]);
});
}
}
ProcessOrder::dispatch($order, $user->locale ?? app()->getLocale());
The trait is small and does one important thing:
public function withLocale($locale, $callback)
{
if (! $locale) {
return $callback();
}
$app = Container::getInstance();
$original = $app->getLocale();
try {
$app->setLocale($locale);
return $callback();
} finally {
$app->setLocale($original);
}
}
Note the finally. It restores the previous locale even if the callback throws — which is the whole reason to use it rather than calling setLocale() directly.
The worker leakage bug
This is the one that produces genuinely confusing reports, so it's worth stating on its own.
A queue worker is a long-lived process handling many jobs. If a job calls App::setLocale('es') without restoring it, that locale persists for every subsequent job on that worker:
// Don't do this in a job.
public function handle(): void
{
App::setLocale($this->locale); // leaks into the next job
Mail::to($this->order->email)->send(new OrderConfirmation($this->order));
}
Job A (Spanish) runs, then Job B for an English-speaking user runs on the same worker and renders in Spanish. The symptom is maddening: some users get the wrong language, seemingly at random, and it correlates with nothing in the code. It depends on job ordering across workers, so it's not reproducible locally with --once, and restarting workers "fixes" it temporarily.
withLocale() prevents it. So does making sure every job sets the locale explicitly rather than relying on inherited state — but restoring is the robust answer.
Subject lines are a separate render
A common half-fix: the email body is Spanish, the subject line is English.
Resolving the subject in envelope() is correct — that method runs at render time, inside whatever locale the mailable was given:
final class OrderConfirmation extends Mailable
{
public function __construct(private readonly Order $order) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __('mail.order_confirmation.subject', ['number' => $this->order->number]),
);
}
}
The mistake is resolving it in the constructor:
public function __construct(private readonly Order $order)
{
// Wrong: resolved at construction, then serialized into the queue payload.
$this->subject = __('mail.order_confirmation.subject');
}
The constructor runs in the dispatching process, where the locale may be right, and the result is frozen into the payload before the worker ever applies the mailable's locale. The rule generalises beyond mail: resolve translations as late as possible.
Markdown mail templates
Markdown mailables include Laravel's own components, which contain English text — notably the footer and the "if you're having trouble clicking" line in @component('mail::button'). Publish and translate them:
php artisan vendor:publish --tag=laravel-mail
That gives you resources/views/vendor/mail/. Wrap the strings in __() as you would anywhere else. It's a small file set and it's the difference between a fully Spanish email and one with an English footer.
The same applies to Laravel's built-in notification template — the Notifications\Notification markdown view has salutation and closing text ("Hello!", "Regards", "If you did not request a password reset, no further action is required.").
Scheduled and system-triggered mail
The cases with no request context at all, where "the user's locale" is the only sensible source.
A scheduled digest looping over users must resolve the locale per recipient, not once:
// Wrong: every recipient gets the locale of whoever/whatever ran the command.
User::each(function (User $user) {
Mail::to($user)->queue(new WeeklyDigest($user));
});
// Right: one job per recipient, locale resolved from the recipient.
User::each(function (User $user) {
SendWeeklyDigest::dispatch($user, $user->locale ?? config('app.locale'));
});
The first version is a common shape and it sends every user the app default. It looks correct because Mail::to($user) reads as recipient-aware — but only the address is.
Webhook-triggered mail has the same shape. A payment provider calls your endpoint; there's no session, and the "current locale" is your default. Resolve from the affected user record:
public function handle(Request $request): Response
{
$subscription = Subscription::findByProviderId($request->input('subscription_id'));
SendPaymentFailed::dispatch(
$subscription,
$subscription->user->locale ?? config('app.locale'),
);
return response()->noContent();
}
Previewing what you're actually sending
Mailables are renderable, which makes locale review much easier than sending test emails to yourself. A small command beats a preview route, because you can loop it over every locale:
Artisan::command('mail:preview {mailable} {locale}', function (string $mailable, string $locale) {
app()->setLocale($locale);
$class = "App\\Mail\\{$mailable}";
file_put_contents(
storage_path("app/preview-{$locale}.html"),
(new $class(Order::factory()->make()))->render(),
);
$this->info("Wrote preview for {$locale}");
});
Run it for every locale you support and open the files. Ten minutes of this before launch catches untranslated footers, broken layouts from text expansion, and subject lines nobody translated — all things that are invisible in the lang files themselves.
Laravel's mailable preview via a route returning the mailable instance works too; the point is to look at the rendered output per locale rather than trusting the file contents.
Testing it
Mail assertions can check the rendered locale, which is what you actually care about:
it('sends the order confirmation in the user locale', function () {
Mail::fake();
$user = User::factory()->create(['locale' => 'es']);
ProcessOrder::dispatchSync(Order::factory()->for($user)->create(), $user->locale);
Mail::assertSent(OrderConfirmation::class, function ($mail) {
return $mail->hasTo('...') && str_contains($mail->render(), 'Gracias por tu pedido');
});
});
More valuable is a test for the leakage case, because it's the one you can't spot by reading code:
it('does not leak the locale between queued jobs', function () {
app()->setLocale('en');
$spanish = User::factory()->create(['locale' => 'es']);
ProcessOrder::dispatchSync(Order::factory()->for($spanish)->create(), 'es');
// After the job, the process locale must be back to where it started.
expect(app()->getLocale())->toBe('en');
});
That test fails against the naive App::setLocale() implementation and passes with withLocale(). It's three lines and it catches a class of bug that otherwise reaches production.
A checklist for a genuinely localized email
For each transactional email, verify:
- Subject — translated, resolved at render time not construction
- Body copy — every string through
__() - Button and link labels — easy to miss inside components
- Footer and vendor templates — published and translated
- Dates —
isoFormat()with the recipient's locale and timezone, notformat(). See localized dates with Carbon - Currency and numbers — formatted for the recipient's locale, not yours
- Plural forms — "1 item" vs "3 items" needs
trans_choice()with the right number of forms for the language Content-Language-equivalent — set the mailable's locale explicitly rather than relying on the worker default
Where the locale should come from
A decision worth making deliberately: emails should follow the recipient's stored preference, not the locale of the request that triggered them. Those differ more often than you'd think — admin actions, system-generated notices, scheduled digests, webhooks from a payment provider.
Store a locale on the user, populate it when they first choose a language, and use it as the source of truth for anything sent to them:
$locale = $user->locale ?? config('app.locale');
For users who never made an explicit choice, config('app.locale') is a more honest default than whatever the triggering request happened to be.
Summary
- Implement
HasLocalePreferenceon your user model — notifications then localize automatically, following the recipient. - Mailables need
->locale(...)explicitly, or wrap the send inwithLocale(). - Use the
Localizabletrait rather than bareApp::setLocale()in jobs: it restores the previous locale in afinally, preventing leakage between jobs on a long-lived worker. - Never resolve
__()in a constructor. Late-render only. - Publish and translate
vendor/mailand the notification templates. - Emails follow the recipient's stored locale, not the triggering request.
- Test that the locale is restored after a job — it's three lines and catches the worst bug here.
Emails are also where missing translations are most expensive: you can't fix a sent email, and the fix for a bad string in lang/es/mail.php is normally a deploy. LangSyncer shows which languages are incomplete before you send, and lets you correct copy in production in seconds without one.