Customers start receiving notifications in the wrong language. Not everyone, and not consistently: a French customer gets French, then the next three people get French too regardless of their settings, then it corrects itself for a while. It never reproduces locally.

The bug is not in your notification code. It is in the fact that a queue worker is one long-lived PHP process, and the previous job left something behind.

A worker boots your app once

queue:work boots the application a single time and then loops, running job after job in that same process. That is what makes it fast, and it is completely different from an HTTP request, where every request starts from nothing and PHP throws the whole world away at the end.

In a worker there is no such reset. Anything you change in memory during one job is still changed for the next one.

What Laravel does reset

Not nothing. Between jobs the worker clears scoped container bindings, the ones registered with scoped():

$this->app->scoped(TenantContext::class, fn () => new TenantContext);

Those get a fresh instance for each job, which is exactly what you want for per-job context. If you have somewhere to hang request-like state, that is the right place.

What it does not reset

Everything else, and this is the list worth memorising:

Static properties. A cache on a class survives for the life of the process:

class ExchangeRates
{
    protected static array $rates = [];   // now shared by every job
}

Singletons. By design, singleton() returns the same instance for the process lifetime. If a service accumulates state, it accumulates it across unrelated jobs.

Config changes. config()->set(...) inside a job is permanent for that worker, not for that job.

The locale. This is the one from the opening. Setting it for the recipient leaks straight into the next job:

App::setLocale($this->user->locale);   // never set back

Authentication state. Logging a user in inside a job leaves them logged in for whatever runs next, which is worse than a wrong language.

Anything you booted lazily and cached yourself, which is the general form of all of the above.

Two ways to fix it

Do not mutate global state. The best version of this bug is the one you cannot write. Pass the locale into the mailable rather than setting it globally, keep values on the job instance instead of on a static, and use scoped() for anything that genuinely is per-job context.

If you must mutate, restore it. Wrap the change so it is undone whichever way the job ends:

$original = App::currentLocale();

try {
    App::setLocale($this->user->locale);

    // ... do the work
} finally {
    App::setLocale($original);
}

The finally matters, because an exception must not leave the process contaminated.

Laravel also gives you a scoped helper for locale specifically:

App::setLocale($this->user->locale);
// or, cleaner:
$message = trans('mail.welcome', [], $this->user->locale);

Passing the locale to the translation call avoids the global entirely, which is the safer habit.

Why it never happens locally

Because locally you probably run queue:listen, or you are on the sync driver, or the worker only ever handles one job at a time before you restart it. Every one of those hides the bug.

queue:listen boots a fresh application per job, so no state carries over. It is much slower, which is why nobody runs it in production, but it explains the "works on my machine" gap perfectly. If you suspect this class of bug, reproducing it means running queue:work with several jobs in a row, not one.

Wrapping up

A queue worker keeps your application in memory between jobs, so statics, singletons, config edits, the locale and auth state all persist. Laravel resets scoped() bindings and nothing else. Prefer passing values over setting globals, restore anything you do change in a finally, and remember that a bug you cannot reproduce with a single job may appear immediately with three.

Related