skip to content

Why can a Laravel queue worker produce one employee's payslip in another employee's language after a job calls App::setLocale(), and how do you prevent it?

level: seniorimportance: should knowfreq 28%

answer

  1. the worker boots the app once
  2. setLocale mutates config and translator
  3. no reset between jobs
  4. restore in finally, or pass the locale
  5. Octane resets it per request

basics

~10 s

queue:work reuses one booted application for many jobs, and App::setLocale() changes its in-memory config and translator, so the next job inherits it. Restore the previous locale in finally, or pass the locale to __().

solid answer

~40 s

Under PHP-FPM every request boots a fresh application, so a locale set with `App::setLocale()` dies with the request. `php artisan queue:work` is different: one long-running process boots the app once and runs job after job in it. `setLocale` writes `app.locale` in memory, updates the translator singleton and fires `LocaleUpdated`, and the worker's between-job reset clears scoped instances, resolved facades and log context, not the locale. So a job that switches to Polish and returns leaves Polish for the next job, whose employee may be German. Fix it by storing the employee's locale on the job, switching inside `try` and restoring the previous locale in `finally`, or by passing the locale per call, `__('payslip.title', [], $locale)`. Octane is not affected in the same way: it clones config per request and resets the translator's locale.

code

php · 27 lines
php
<?php

namespace App\Jobs;

use App\Models\Employee;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\App;

class GeneratePayslip implements ShouldQueue
{
    use Queueable;

    public function __construct(public Employee $employee) {}

    public function handle(): void
    {
        $previous = App::currentLocale();

        try {
            App::setLocale($this->employee->locale);
            // render the payslip with __() and trans_choice() ...
        } finally {
            App::setLocale($previous);
        }
    }
}

go deeper

for a junior

Know that a queue worker is one long-running process, so a locale change inside a job can carry over to the next job.

for a middle

Explain what setLocale() changes, why the worker's between-job reset does not cover it, and how try/finally or a per-call locale prevents the leak.

for a senior

Show the production habits: locale as job data, restore in finally, back-to-back job tests, and knowing Octane resets per request while workers do not.

for a principal

Set a codebase rule for global state in long-lived processes, deciding which state jobs may mutate and how reviews and tests enforce it.

## Why a worker is different Laravel's normal web model under PHP-FPM is **share-nothing**: each request boots the application, handles one request and throws everything away. Any `App::setLocale()` call is forgotten automatically. A queue worker started with `php artisan queue:work` is a **long-running process**. It boots the application once and then loops: fetch a job, run it, fetch the next. Everything held in memory, including config values and container singletons, survives from one job to the next unless something resets it. Between jobs the worker does run a reset step. In Laravel 13 it: - forgets **scoped** container instances; - clears the **facade** instance cache; - flushes shared **log context**; - resets per-connection query-duration tracking. It does **not** restore `app.locale` or the translator's locale. ## What setLocale touches `App::setLocale('pl')` changes three things, all of them process-wide in a worker: 1. the config value `app.locale`; 2. the locale of the **translator singleton**; 3. anything listening for `LocaleUpdated`, such as Carbon's service provider, which switches Carbon's locale too. So after a Polish payslip job returns without restoring, the next job, for a German employee, renders its `__()` lines and Carbon dates in Polish. ## Ways to prevent it | Approach | How | Trade-off | |---|---|---| | Restore in `finally` | remember `App::currentLocale()`, switch, restore after | reliable; every job must do it | | Pass the locale per call | `__('payslip.title', [], $locale)`, `trans_choice($key, $n, [], $locale)` | no global state; Carbon and other listeners do not follow | | Fresh process per job | `php artisan queue:listen` | isolates everything; boots the framework for every job | The first option is the usual answer. The locale must travel **with the job**: there is no HTTP request in a worker, so read the employee's saved preference when the job runs, or pass it to the constructor when dispatching. ## Octane is a different case Octane also keeps a booted application between requests, but it resets state for each one. Its `CreateConfigurationSandbox` listener clones the config repository per request, and `FlushLocaleState` sets the translator's locale and fallback back from that config and re-applies Carbon's locale. A request that calls `App::setLocale()` therefore does not leak into the next request. That protection covers Octane's request loop, not queue workers. ## A checklist for background work - Every job that renders text takes the target locale as data. - Every job that switches the global locale restores it in `finally`, so exceptions cannot leak it either. - Tests run two jobs with different locales back to back in one process and assert the second one's output. - Code review treats a bare `App::setLocale()` in a job, command or listener as a bug unless it is paired with a restore. ## Pitfalls - Assuming the web middleware that set the locale also runs for jobs: jobs never pass through HTTP middleware. - Using `Lang::setLocale()` instead: it changes the translator singleton and leaks just the same, while leaving `app.locale` stale. - Restoring only on the success path, so a failed job leaves its locale behind for the retry and everything after it. ## Testing for the leak A focused test catches the bug before production does: 1. Create two employees, one with `pl` and one with `de`. 2. Run the payslip job for the Polish employee, then for the German one, in the same test process. 3. Assert that the second payslip contains German text and that `App::currentLocale()` equals the configured default afterwards. 4. Add a variant in which the first job throws, to prove the `finally` block restores the locale on the failure path too.

  • Why not simply pass the locale to every __() call instead of switching?
    That works for translation lines and leaves no global state, but only for code you call directly. Views, components, Carbon dates and any library that reads the application locale still see the worker's current locale, so jobs that render whole documents usually switch and restore instead.
  • Does queue:listen avoid the leak, and why is it rarely used in production?
    Yes: it starts a fresh `queue:work --once` process for each job, so no memory carries over. The cost is booting the whole framework per job, which is much slower than a long-running worker.

saying these in an interview costs you the question

  • The queue worker resets the locale to APP_LOCALE before each job
  • Jobs run through the same HTTP middleware that set the request's locale
  • Lang::setLocale() is safe in jobs because it leaves app.locale alone
  • Restoring the locale after the job's success path is enough
  • Under Octane a setLocale call leaks into the next request