In a Laravel queue worker serving several gym branches, why can Config::set('services.payment.key', ...) inside one job charge the next job with the wrong key?
answer
- in-memory repository only
- lasts as long as the process
- queue:work boots once for many jobs
- worker resets scoped instances, not config
- already-built services keep the old value
basics
~20 sConfig::set only changes the in-memory config repository, which lives as long as the PHP process. A queue worker handles many jobs in one process and does not reset config between them, so a key set for one branch leaks into later jobs.
solid answer
~40 s`Config::set()` (or `config([...])`) writes into the config repository's array with `Arr::set`; nothing is saved to a file or a cache. Under PHP-FPM each request boots a fresh application, so a change dies with the request. A `queue:work` process boots once and handles job after job; between jobs it forgets scoped instances and clears resolved facade instances, but the config repository is untouched. So a renewal job for branch A that sets `services.payment.key` leaves A's key in place for the next job, and a branch that relies on the default is charged on A's account. A second trap: a singleton payment client built before the `set()` keeps the key it was constructed with. Pass per-branch credentials explicitly to the client instead, or restore the original value in a `finally` block.
code
php · 25 lines<?php
namespace App\Jobs;
use App\Models\Branch;
use App\Payments\Gateway;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RenewMemberships implements ShouldQueue
{
use Queueable;
public function __construct(public Branch $branch) {}
public function handle(Gateway $gateway): void
{
// No Config::set: the branch's key is passed explicitly.
$client = $gateway->forAccount(
$this->branch->payment_key ?? config('services.payment.key')
);
$client->chargeDueMemberships($this->branch);
}
}go deeper
Know that Config::set and config([...]) change a value only in memory for the running process, and that the files on disk stay the same.
Explain process lifetimes: a web request boots fresh, a queue worker keeps one application for many jobs, and each test gets a new application.
Spot global config used as per-tenant state, pass credentials explicitly or restore values in finally, and check which services were built before the override.
Set a rule that tenant-specific values never live in global config, and review long-running processes for any state that must be reset between units of work.
## What `Config::set` actually does The config repository (`Illuminate\Config\Repository`) is an object holding one big nested array. Both of these write into it: ```php Config::set('services.payment.key', $branch->payment_key); config(['services.payment.key' => $branch->payment_key]); ``` `set()` walks the dot path with `Arr::set` and replaces the value **in memory**. It does not edit `config/services.php`, touch `.env`, or change a config cache file. Every later `config('services.payment.key')` **in the same process** sees the new value. How long "the same process" lasts is the whole question: | Runtime | Lifetime of a `Config::set` change | |---|---| | PHP-FPM or `artisan serve` web request | until the request ends; the next request boots fresh | | A single Artisan command | until the command exits | | `queue:work` worker | until the worker process exits, across every job it handles | | Each test in a Laravel test case | until that test ends; the app is rebuilt per test | ## The gym-branch scenario A gym chain runs one Laravel app for all branches. Each branch has its own payment account. A nightly job renews memberships per branch: 1. The worker picks up `RenewMemberships` for branch A, which calls `Config::set('services.payment.key', $a->payment_key)` and charges A's members. 2. The same worker picks up the job for branch B, which uses the chain's default account and so does not set anything. 3. B's job reads `config('services.payment.key')` and gets **A's key**. Its members are charged on the wrong account. Why nothing cleans it up: the worker runs a reset step between jobs. It flushes shared log context, resets per-connection query-duration tracking, calls `forgetScopedInstances()` and clears resolved facade instances. The config repository is a plain container instance, not a scoped one, so its array survives. ## The second trap: values already captured Even inside one request, `Config::set` only affects code that reads config **after** the call. If a `PaymentClient` singleton was built earlier with `new PaymentClient(config('services.payment.key'))`, it holds the old key in a property. Changing the config does not reach it, and in a worker the same singleton is reused for every job. ## Safer patterns - **Pass per-tenant values explicitly.** Build or configure the client with the branch's credentials: `$gateway->forAccount($branch->payment_key)`. Nothing global changes. - **If you must override global config, restore it.** Read the old value, set the new one, and put the old value back in a `finally` block so a thrown exception does not leave the override behind. - **Prefer scoped or per-call objects for tenant state.** A binding registered with `scoped()` is forgotten between jobs by the worker; a singleton is not. - **Tests are the legitimate home of `config([...])`.** Each test gets a fresh application, so overrides do not leak between tests. - **Typed getters catch surprises.** `Config::string()`, `Config::integer()`, `Config::boolean()`, `Config::array()` and friends throw `InvalidArgumentException` when the stored value has the wrong type, which surfaces a bad override early. ## Code that restores the value ```php $previous = config('services.payment.key'); try { Config::set('services.payment.key', $branch->payment_key); $this->chargeMembers($branch); } finally { Config::set('services.payment.key', $previous); } ``` This works for code that reads config at call time. It still does nothing for a client object that captured the key before the override, which is why explicit parameters are the better design. ## Diagnosing a suspected leak Symptoms that point to leaked config in a worker are intermittent and order-dependent: a charge goes to the wrong account only when a particular job ran earlier on the same worker, and restarting the worker makes the problem disappear for a while. To confirm it: 1. Log `config('services.payment.key')` (masked) at the start of each job, along with the worker's process id. 2. Look for a value that changes after a specific job class runs and then stays changed for later jobs on the same process. 3. Search the job and anything it calls for `Config::set` and `config([`. Running the same jobs through the `sync` queue connection in a test hides the bug, because each test gets a fresh application; the leak only appears when one process handles several jobs in a row. ## What interviewers listen for - `Config::set` is runtime-only and process-scoped. - Long-lived workers make "process-scoped" mean "across jobs". - Objects that already read config do not see later changes. - The fix is to stop using global config as per-tenant state.
- Would the same Config::set leak between two web requests under PHP-FPM?No. Under PHP-FPM each request bootstraps a new application and a new config repository from the files or the config cache, so an override disappears when the request ends. The leak appears only in processes that serve many units of work after one boot, such as `queue:work` or a long-running Artisan command.
- Is config([...]) in a feature test a problem for later tests?No. Laravel's test case creates a fresh application for each test, so a `config(['services.payment.key' => 'test'])` override lives only for that test. It is the intended way to change a setting under test, and it needs no cleanup.
saying these in an interview costs you the question
- Config::set writes the new value back into config/services.php
- Queue workers reload configuration from disk before every job
- A singleton built earlier picks up the new config value automatically
- Config::set changes are shared by every server behind the load balancer
- Overrides with config([...]) in one test leak into the next test