The Invisible Side Effects of Global State
How global state creates an invisible dependency, leaks between tests and grows riskier in queue workers and Octane, and how to make state visible and local
TL;DR
Global state creates a dependency that the function signature does not show: a value changed in one place is read in another unrelated one, state leaks between tests and the bug is found only while debugging. The danger grows in long-running processes such as queue workers and Octane. The fix is not to eliminate state but to make it visible and local through parameters, dependency injection and request or job scope.
There was a bug where a value changed “on its own.” The test passed when run alone, but failed when run with the whole suite. One request behaved differently depending on the request that came before it. The culprit was shared, mutable global state.
Global state shortens the code — and lengthens the debugging.
What “global” means
“Global” here is more than the global keyword. Static properties carrying mutable state, stateful singletons, module-level mutable variables, shared state hidden behind a framework facade — all belong to the same category.
The common trait: a value changed in one place can be read in another, unrelated place.
The invisible coupling
The real cost of global state is the invisible coupling it creates. A function’s signature tells you what it needs — its parameters. But if the function reads and mutates a global, the signature lies. The function depends on something its signature never shows.
This is called “action at a distance”: a change in one file alters the behavior of another file that has no visible connection to it. You can’t see this coupling by reading the code; you discover it only while debugging.
Untestability
The earliest signal of global state shows up in the tests. State leaks between tests: one test sets a global, the next test finds it already set. Tests become order-dependent — they pass alone and fail together.
The workaround is to reset the globals before every test. That very need to reset is the smell itself: the code confesses, through its test setup, that it depends on shared state.
The danger in long-running processes
In PHP’s classic request-response model, global state resets on every request; the danger is partly hidden. But in a long-lived process — in a queue worker — it’s a different story.
The worker stays alive between jobs. A value written to a global by job 1 leaks into job 2. A bug invisible when you test locally with a single job, exploding in production when thousands of jobs run back to back. Persistent application servers like Octane bring the same risk to classic web requests too.
Localizing state
The solution isn’t to eliminate state — every system has state — it’s to make it visible and local:
- Instead of reading state from a global, pass it as a parameter. An explicit dependency, visible in the signature.
- Supply dependencies through dependency injection, not hidden access.
- Keep mutable state scoped to the request or job, not the process. Let a job’s state begin and end with that job.
The rule is plain: explicit beats implicit. If everything a function needs is visible in its signature, that function is both readable and testable.
Global state is tempting because it saves you from writing a few parameters. In return, it makes you hunt for hours to find where a value changed.
Every global is a hidden coupling left in the code — turn it into a parameter.