Wednesday, 30 September 2026

The Hidden Cost of Global State in PHP

The Hidden Cost of Global State in PHP

Global state is convenient.

That is why it survives.

A static singleton is easy to reach.

A global helper saves a few lines.

A shared container gives you access to almost anything.

A static “current user” or “current tenant” feels harmless when every request starts in a fresh PHP process.

And for years, that model worked reasonably well because PHP-FPM gave developers something valuable for free:

process termination cleaned up the mess.

But once PHP applications start living longer, the cost of global state becomes much easier to see.

Why global state feels harmless

Imagine code like this:

CurrentTenant::set($tenant);

Later:

$tenant = CurrentTenant::get();

Simple.

No need to pass tenant context through several layers.

No need to redesign service interfaces.

The problem is that this convenience hides ownership.

Who set it?

Who is allowed to change it?

When does it stop being valid?

Who clears it?

What happens when another execution starts?

Those questions are easy to ignore in short-lived request models.

They become much harder to ignore in persistent ones.

The old PHP lifecycle hid a lot

Traditional PHP applications often behave roughly like this:

Request arrives
    ↓
PHP process handles request
    ↓
Response returned
    ↓
Request state disappears

Even if some code accidentally leaves state behind, the process lifecycle often resets everything.

That creates a kind of accidental safety.

Now consider:

Worker boots once
    ↓
Execution A
    ↓
Execution B
    ↓
Execution C
    ↓
Execution D

Suddenly:

CurrentUser::$user
CurrentTenant::$tenant
DatabaseContext::$transaction
TelemetryContext::$trace

may survive longer than expected.

If cleanup is incomplete, Execution B can inherit assumptions from Execution A.

That is not just messy architecture.

It can become a correctness or security problem.

The dangerous part is invisibility

Global state creates dependencies that often do not appear in function signatures.

Suppose this method looks simple:

public function calculateInvoice(Order $order): Invoice

But internally it reads:

CurrentTenant::get();
CurrentUser::get();
CurrencyContext::get();
FeatureFlags::current();

The real dependency graph is much larger than the method tells you.

That makes the code harder to:

  • test,
  • reason about,
  • reuse,
  • run concurrently,
  • move into another process,
  • isolate by tenant,
  • execute safely in long-running workers.

The function signature says one thing.

The runtime reality says another.

That mismatch is where a lot of complexity hides.

Global state makes concurrency harder

Even if your PHP application does not use threads, concurrency still matters.

Async runtimes, Fibers, workers and interleaved tasks can all expose assumptions that were invisible before.

Imagine:

Task A → Tenant A
Task B → Tenant B

If both rely on the same mutable global:

CurrentTenant::set(...)

you now have a race over shared context.

The exact implementation may differ depending on the runtime, but the architectural problem is the same:

execution-specific data is stored somewhere broader than the execution itself.

That is a lifetime mismatch.

Globals also complicate testing

Global state can make tests appear order-dependent.

One test runs:

CurrentUser::set($admin);

The next test assumes no user is set.

If cleanup is incomplete, the second test fails.

Or worse, it passes for the wrong reason.

This is why some test suites only work when run individually but fail in random order.

The test runner is exposing the same problem that a long-running runtime eventually will:

state escaped its intended lifetime.

Static does not always mean bad

It is important not to overcorrect.

Not every static method or global value is dangerous.

This:

Uuid::fromString($value);

is very different from:

CurrentUser::set($user);

The first is essentially behavior.

The second stores mutable execution state.

The issue is not the static keyword itself.

The more useful question is:

Does this value change between executions?

If yes, then its lifetime matters.

Configuration loaded once at boot may legitimately be application-scoped.

The current user probably is not.

The current tenant probably is not.

The active transaction definitely is not.

Lifetime should match meaning

One useful way to think about state is:

Application lifetime
Execution lifetime
Transient lifetime

Application state might include:

configuration
immutable metadata
service definitions
shared infrastructure clients

Execution state might include:

current request
current user
current tenant
trace context
transaction context

Transient state exists only while one operation is being performed.

Problems begin when something with execution meaning is stored in application lifetime.

That is how stale state survives.

Passing everything everywhere is not the answer

The alternative to global state is not necessarily turning every method into this:

process(
    $request,
    $user,
    $tenant,
    $transaction,
    $trace,
    $locale,
    $timezone,
    $featureFlags
);

That can become ugly too.

Good architecture usually gives execution context a clear home.

For example:

ExecutionContext

might contain the state that belongs to one execution.

Services that genuinely need that context can depend on it explicitly.

Other services do not.

The important point is not the exact class design.

It is that ownership and lifetime become visible.

Cleanup matters as much as construction

Persistent systems often focus on setup:

start execution
attach context
open transaction
start trace

But safe reuse depends just as much on the end:

finish execution
close transaction
flush telemetry
release references
clear execution context
verify reusable state

If cleanup fails, blindly reusing the process may be unsafe.

That is why I think cleanup should be treated as part of execution correctness, not as an optional housekeeping step.

This is one reason EvolvePHP treats execution as a first-class concept

One of the ideas behind EvolvePHP's runtime model is that application lifetime and execution lifetime should not be confused.

A long-running process may survive thousands of executions.

The user should not.

The tenant should not.

The transaction should not.

The trace should not.

Execution-specific state should belong to the execution and be cleaned up with it.

And if cleanup cannot be trusted, the safer choice may be to stop reusing that process.

That is more conservative than assuming everything is fine.

I think persistent runtimes need that conservatism.

Global state is often technical debt disguised as convenience

Global state saves effort at the point where it is introduced.

The cost arrives later.

It appears when:

  • tests become unpredictable,
  • modules become difficult to isolate,
  • background jobs reuse stale context,
  • tenant information leaks,
  • concurrent tasks interfere,
  • services become difficult to extract,
  • persistent workers behave strangely.

The difficult part is that the original line of code looked completely reasonable.

That is why this problem survives.

Global state is rarely expensive on day one.

It becomes expensive when the system starts changing.

And in long-lived PHP applications, change is exactly what the architecture needs to survive.

No comments:

Post a Comment