A queued job selected the settings for one business context by overwriting Laravel’s global configuration. The values were correct for that job. The problem was what happened afterward.
The worker stayed alive.
Only a small fraction of the queued job classes selected their context this way. A later job could read a key without first replacing it and inherit whatever the previous job had left behind. Nothing was wrong with the configuration file, environment variables, or deployment. The process contained valid values for the wrong work.
Calling that a configuration bug hides the mechanism. Once application code can mutate configuration at runtime, configuration is process state.
The lifetime is longer than the call
Laravel’s current documentation describes both halves of this behavior. Applications may set configuration values at runtime , and queue workers are long-lived processes which keep the booted application in memory. A daemon worker does not reboot the framework before every job.
Put those facts together:
worker starts
-> application configuration is loaded
-> job A overwrites a shared key
-> job A finishes
-> worker remains alive
-> job B reads the shared keyThe mutation is not scoped to the method which made it. It is not scoped to the job either. Unless something restores or replaces the value, its lifetime is the lifetime of the worker process.
This is easy to miss in a web request because Laravel normally boots a fresh application for each request even when PHP’s process manager reuses a worker. Tests commonly construct a fresh application between cases. Long-running queue workers remove that accidental isolation.
The same class of problem appears in application servers, command loops, test
suites, and local development servers. The important question is not whether
the setting lives in a file named config. It is whether the value can change
inside a process which will handle unrelated work later.
Valid values can still be wrong
State leakage does not require malformed data. Suppose two contexts have different surcharge values:
base value: 5
context north: 8
context south: 13Job A selects south, mutates the shared key to 13, and calculates the
correct result. Job B belongs to north, but its path never performs the
mutation. It reads 13.
Every individual value is permitted. Type validation passes. The key exists. The bug is provenance: job B cannot explain why that value belongs to its context.
The DBG-05 fixture runs those two jobs through one synthetic worker. The mutable version gives the second job the first job’s value. The resolver version accepts the context at the point of use and returns the correct value for both jobs, regardless of order.
The fixture does not run Laravel or reproduce the private application. It isolates the lifetime rule:
ambient read = whichever value this process currently holds
explicit read = value selected for this workThat distinction is more useful than adding another configuration validation rule. Both values are valid. Only one belongs to the current job.
Count writers, readers, and reset boundaries
The retained application made the risk measurable in code. One action mutated several global keys from a business-context identifier. At the time of the repair, repository inspection found 76 queued job classes and only two which called that action.
That count does not prove 74 jobs read the affected keys. Some had no relationship to them. It disproves a more convenient assumption: context initialization was not a universal queue boundary.
A useful inventory asks three separate questions:
| Question | Why it matters |
|---|---|
| Who writes this key after boot? | identifies the transitions which can contaminate later work |
| Who reads it without an explicit context? | identifies consumers which depend on ambient state |
| What always resets it before the next unit of work? | determines whether the mutation is actually scoped |
Searches for a setter alone are insufficient. The affected reader may use a helper, a facade, framework integration, or package code. Searching readers alone is also insufficient because a value loaded once at boot is usually stable until runtime code introduces another writer.
The reset question deserves proof, not reassurance. “Middleware normally sets it” says nothing about jobs which bypass that middleware. “Most jobs initialize the context” still leaves order-dependent behavior in the remainder. “Workers restart regularly” limits how long the contamination lives; it does not stop the next job from observing it.
Resolve business values where they are used
The repair moved context-specific values behind dedicated resolvers. A surcharge calculation receives the business-context identifier and asks for the matching fee. Another calculation asks for the effective amount for its subject, including a subject-specific override. An integration selects credentials from the current context when it creates the client.
In simplified form:
final class FeeResolver
{
public function forContext(int $contextId): int
{
return match ($contextId) {
1 => config('fees.per_context.1'),
2 => config('fees.per_context.2'),
default => config('fees.default'),
};
}
}The global configuration still holds the catalogue. The resolver stops copying one entry into a shared “current” key.
This changes the design in three useful ways:
- The call site reveals that the decision depends on context.
- The selected value can be tested without arranging a previous mutation.
- Job order no longer participates in the result.
The resolver is not valuable because it adds a class. It is valuable because it shortens the value’s effective lifetime from “until this process overwrites it again” to “for this decision.”
An immutable value object passed into the job can make that boundary stronger, especially when the correct value should be fixed at dispatch time. Looking up the value during execution is better when current configuration should apply. That timing choice is part of the contract; a global mutable key leaves it accidental.
Resetting can be a legitimate boundary
Removing ambient state is not always practical. Some framework integrations read global configuration internally and offer no context parameter. In that case, a disciplined scope can be sufficient:
$previous = config('service.credentials');
try {
config(['service.credentials' => $credentialsForThisJob]);
$operation();
} finally {
config(['service.credentials' => $previous]);
}This approach has costs. Every exit path must restore the previous value. Deferred callbacks must not outlive the scope. Concurrent work in the same process would still make a single shared key unsafe. Nested scopes need stack semantics rather than a reset to a hard-coded default.
A worker-level initializer is another valid option when every job carries the same context contract and the framework guarantees that the initializer runs before job code. It should begin from a known base and fail when context is missing. Merely adding the initializer to the jobs which currently fail preserves the same partial-coverage problem.
Process isolation is the strongest reset. A fresh process for every job removes this particular leak, but it trades state isolation for startup cost and does not make ambient dependencies easier to see. Restarting long-lived workers during deployment remains necessary for new code and booted configuration. It is not a per-job scoping strategy.
Some global settings are genuinely global
The retained repair did not remove every runtime mutation. A small set of framework-owned settings remained because the framework integration expected them globally and changing that boundary would have required a wider redesign.
That is not an argument to leave all of the mutations in place. It is a reason to classify them:
process invariant
loaded at boot and unchanged until restart
work input
selected from the current request, tenant, account, or job
scoped framework setting
temporarily installed because an integration requires ambient stateEach category needs a different lifecycle. A process invariant belongs in deployment configuration. A work input belongs in an argument, value object, or resolver. A scoped framework setting needs a proven setup and restoration boundary.
The dangerous category is “work input stored as though it were a process
invariant.” It looks convenient because every reader can call config(). The
hidden cost is that every reader now depends on which work the process handled
before.
Test order, not only values
A test for the resolver should prove the precedence rules for each context. A test for the old failure needs one more dimension: sequence.
run context south, then context north
run context north, then context south
run work which sets context, then work which does notIf the result changes with job order, the test has found shared mutable state. A single isolated test cannot reveal that dependency because its starting state is always clean.
This is also the reversal condition for the article’s main claim. Runtime mutation is not a cross-job defect if the process is guaranteed to terminate after the operation, if every work boundary resets all affected keys before any reader runs, or if no later work can read them. Those are architectural properties which can be tested. They should not be inferred from the fact that most requests appear correct.
Configuration deserves review and validation, but those words are too broad to diagnose this failure. The decisive property was lifetime. One job changed shared process state, another job could observe it, and no universal boundary stood between them.
When a setting varies by the work being handled, make that work an input to the decision. Otherwise the worker’s history becomes an undocumented configuration source.