Friday, 21 August 2026

Why Persistent PHP Workers Need Execution Isolation

 


For most of PHP's history, developers have had a very useful safety net.

A request comes in.

PHP handles it.

The response is returned.

Then the request-specific state effectively disappears with the process lifecycle.

Conceptually:

Request starts
    ↓
Application runs
    ↓
Response returned
    ↓
Request state disappears

That model has shaped the way PHP applications are written for years.

It is simple.

It is predictable.

And, whether we realize it or not, it protects us from a lot of mistakes.

But persistent PHP workers change that assumption.

When the same PHP process handles more than one request, message or task, state from the previous execution can survive into the next one.

That is where execution isolation becomes important.

And it is one of the reasons EvolvePHP 2 treats an execution as a first-class architectural concept.

Persistent workers change the rules

Traditional PHP-FPM gives applications a relatively disposable execution model.

A request might look like this:

Request A
    ↓
PHP process
    ↓
Response A

Later another request arrives.

From the application's point of view, it is generally working with a clean request lifecycle.

Persistent runtimes work differently.

The process may stay alive:

Application boots once
        ↓
Request A
        ↓
Request B
        ↓
Request C
        ↓
Request D

That can be extremely useful.

The framework does not need to bootstrap everything from scratch every time.

Services can remain warm.

Configuration may already be loaded.

Expensive initialization can be reused.

This can improve performance significantly.

But there is a cost.

The process now has memory.

And that memory can become dangerous.

Imagine a very simple mistake

Suppose somebody writes something like this:

final class CurrentUser
{
    public static ?User $user = null;
}

During Request A:

CurrentUser = Alice

The request completes.

Under a disposable process model, the problem may never become obvious.

The process goes away.

Then Request B starts somewhere else.

But under a persistent worker, Request B may enter the exact same process.

If cleanup did not happen correctly:

Request A
CurrentUser = Alice

        ↓

Request B
CurrentUser = Alice   ← still there

Now Bob's request could observe Alice's state.

That is no longer just an implementation bug.

It is potentially a security vulnerability.

The same problem applies to much more than users

The current authenticated user is only the easiest example.

Execution-specific state can include:

  • tenant information,

  • locale,

  • timezone,

  • authorization decisions,

  • database transactions,

  • ORM state,

  • request metadata,

  • trace context,

  • logging context,

  • event listeners,

  • temporary caches,

  • feature flags,

  • impersonation state,

  • connection state.

Imagine a multi-tenant SaaS application.

Request A belongs to:

Tenant A

Then the worker handles another request for:

Tenant B

If some service accidentally retains the previous tenant:

Tenant A state
      ↓
worker reused
      ↓
Tenant B request

the consequences can be serious.

Cross-tenant leakage is one of the worst classes of bugs a SaaS system can have.

This is why "request scope" is not enough for me

When I started thinking about EvolvePHP 2, I did not want the framework architecture to assume that all application work is an HTTP request.

Modern applications do much more.

They process:

HTTP requests
queue messages
scheduled jobs
CLI commands
worker tasks

A queue worker has the same fundamental problem as a web worker.

It handles one piece of work.

Then another.

Then another.

So I prefer a broader term:

Execution.

An execution is one isolated unit of application work.

That gives us a model like:

Application
    |
    +-- Execution A
    |
    +-- Execution B
    |
    +-- Execution C

Each execution gets its own state.

And when it ends, that state must not become visible to the next execution.

EvolvePHP uses three foundational lifetimes

The current EvolvePHP 2 architecture is built around three basic service lifetimes:

Application
Execution
Transient

Application lifetime

These services may exist for as long as the booted application exists.

Examples might include:

configuration
router definitions
immutable metadata
connection pools
shared infrastructure

They must not accidentally hold state that belongs to one execution.

Execution lifetime

These services belong to exactly one unit of work.

Examples could include:

current user
current tenant
request context
authorization context
transaction context
execution logger context

When that execution ends, they end with it.

Transient lifetime

These are created when needed and are not automatically shared.

This sounds simple.

But the dependency rules are what make it useful.

A long-lived service must not capture short-lived state

Consider this:

Application Service
       ↓
Current User

If the application service lives for the entire worker lifetime, but Current User belongs to one execution, we now have a lifetime mismatch.

The long-lived service can accidentally retain the shorter-lived object.

EvolvePHP treats that as an architectural problem.

Conceptually, this direction is allowed:

Execution-scoped service
        ↓
Application-scoped service

But this is dangerous:

Application-scoped service
        ↓
Execution-scoped service

The longer-lived service should not capture the shorter-lived state.

That is one of the things a container or architecture checker can help prevent.

It is much better to reject that relationship early than discover it after production traffic starts leaking state.

Cleanup is not an optional courtesy

Even with correct lifetimes, some reusable infrastructure may hold mutable state.

That means the end of an execution needs a deliberate cleanup phase.

Conceptually:

Execution begins
    ↓
Work runs
    ↓
Primary result captured
    ↓
Cleanup
    ↓
Reset reusable state
    ↓
Can this process safely run again?

That final question matters.

A framework should not assume cleanup succeeded simply because it tried to perform cleanup.

What happens when cleanup fails?

This is where I think persistent-worker safety becomes more interesting.

Suppose the application work succeeds:

Order created successfully

Then cleanup fails.

Maybe a reset participant throws an exception.

Maybe a transaction state cannot be confirmed.

Maybe telemetry context cannot be safely detached.

Maybe some execution-specific resource cannot be closed.

The application result and the cleanup result are two different things.

EvolvePHP's direction is to preserve that distinction.

Conceptually:

Primary outcome: SUCCESS
Cleanup outcome: FAILURE
Process reuse:   UNSAFE

The successful business operation should not magically become a failed business operation simply because cleanup failed afterward.

But the process should also not quietly accept another execution.

That is where quarantine comes in.

Quarantine means "do not trust this process anymore"

The idea is simple:

Cleanup successful
       ↓
worker may be reused

but:

Cleanup failed or uncertain
       ↓
worker quarantined
       ↓
no new execution

Quarantine is not the same as killing the process immediately.

The framework's responsibility is to make the safety decision:

This process is no longer proven safe for reuse.

A runtime adapter or worker supervisor can then decide how to recycle or terminate it.

That separation is intentional.

Core framework code should not need to know whether the application is running under FrankenPHP, RoadRunner, a queue worker or some future runtime.

It only needs to know:

Reusable
or
Quarantined

Failure should not automatically poison the worker

There is another side to this.

Suppose application code throws an exception.

That does not necessarily mean the PHP process is corrupted.

For example:

handler failure
cleanup success

could still result in:

Primary outcome: FAILURE
Cleanup outcome: SUCCESS
Process reuse:   SAFE

That distinction is important.

An application-level exception and a runtime-integrity failure are not the same thing.

The worker should not necessarily be discarded just because one request returned an error.

What matters is whether execution state has been safely isolated and cleaned up.

The full model looks more like this

                Handler
                  |
          +-------+-------+
          |               |
       success          failure
          |               |
          +-------+-------+
                  |
               cleanup
                  |
          +-------+-------+
          |               |
       success          failure
          |               |
        reuse          quarantine

More specifically:

handler success + cleanup success
→ success + reusable

handler failure + cleanup success
→ failure + reusable

handler success + cleanup failure
→ success preserved + quarantine

handler failure + cleanup failure
→ original failure preserved + quarantine

I like this model because it separates business outcome from process safety.

The original failure should remain the original failure

Imagine application code fails because:

PaymentAuthorizationException

Then cleanup also fails.

A framework could easily replace the original exception with:

CleanupException

Now the most important information is lost.

The application's real failure has been hidden by lifecycle cleanup.

EvolvePHP's design direction is to preserve both:

Primary failure
    +
Cleanup failure

The primary failure stays identifiable.

Cleanup failure becomes additional lifecycle information.

And because cleanup failed:

reuse = false

This gives operations and observability systems a much clearer picture of what actually happened.

Execution identifiers help us reason about this

Every execution should have a unique identifier.

For example:

execution: 01K...

That identifier exists before application code runs.

It remains stable for that execution.

And it can be included safely in:

logs
diagnostics
telemetry
error reports

Then you can trace something like:

Execution ABC
kind: HTTP
handler: failed
cleanup: failed
reuse: quarantined

That becomes especially useful when the same application process handles thousands of units of work.

The execution ID is not necessarily the same thing as an HTTP request ID or OpenTelemetry trace ID.

It has a simpler job:

identify one unit of framework work.

Global "current execution" state is tempting

There is a very convenient design pattern that looks like this:

Execution::current()

or:

CurrentContext::get()

available globally from anywhere.

That can make APIs feel simple.

But it also creates ambient mutable state.

And ambient mutable state is exactly what becomes dangerous in long-running workers.

If the framework keeps:

global current user
global current tenant
global current execution

then everything depends on the framework resetting those globals perfectly.

I would rather make execution state explicit wherever practical.

That may require slightly more discipline from developers.

But explicit dependencies are easier to understand, test and isolate.

Sequential execution should be the baseline

Persistent workers lead naturally to another question:

Why not process multiple executions concurrently inside the same process?

Maybe eventually.

But concurrency changes the safety model dramatically.

If two executions run simultaneously:

Execution A ────────┐
                    ├── same process
Execution B ────────┘

then any mutable application-level state becomes much more dangerous.

Now isolation cannot depend merely on "reset before the next request."

There may be no "next" request.

The two executions overlap.

So the conservative approach is:

Execution A
    ↓
complete + cleanup
    ↓
Execution B

until concurrency isolation has explicit contracts and evidence behind it.

I think frameworks should earn concurrency support rather than assume it.

Performance should not come before isolation

Persistent workers are attractive because of performance.

Applications can avoid repeated bootstrapping.

Containers remain warm.

Route metadata may already be compiled.

Expensive setup can be reused.

That is good.

But the optimization is not worth introducing cross-request state leakage.

I would rather have:

slightly slower
correctly isolated

than:

very fast
occasionally leaks tenant state

Performance work should happen after the lifecycle model is safe.

And EvolvePHP's design should make the cost of isolation measurable so we can optimize it without weakening it.

This is especially important in enterprise software

The risk becomes more serious as applications become more valuable.

Imagine persistent workers handling:

banking requests
health records
government workflows
SaaS tenant data
payment processing
enterprise approvals

An execution leak does not just produce a strange UI bug.

It could expose confidential information or apply one customer's context to another customer's operation.

That is why execution isolation belongs in the framework architecture rather than in a deployment guide titled:

"Things to remember when using long-running workers."

The runtime model should make unsafe behavior harder.

Testing execution isolation needs to be aggressive

A few happy-path unit tests are not enough.

If EvolvePHP eventually claims production support for a persistent runtime, I want that claim backed by repeated-execution evidence.

For example:

Execution 1
authenticated user A
tenant A

Execution 2
anonymous
no tenant

Execution 3
authenticated user B
tenant B

Execution 4
handler throws

Execution 5
cleanup fails

Execution 6
must not run in quarantined worker

Repeat variations of that thousands or hundreds of thousands of times.

Then verify:

no previous user remains
no previous tenant remains
no stale transaction remains
no stale execution service remains
no stale telemetry context remains
memory does not grow without bound
cleanup failure always produces quarantine

This is part of the broader Evolve Assurance direction.

The point is not to claim that bugs are impossible.

The point is to make safety claims measurable and continuously test them.

Persistent PHP is an opportunity

I don't see persistent runtimes as a problem PHP should avoid.

They are an opportunity.

PHP applications can benefit from:

warm boot state
long-lived workers
lower initialization overhead
queue processing
modern application servers

But adopting that runtime model responsibly means acknowledging that one of PHP's historical safety nets is disappearing.

The process does not necessarily forget everything anymore.

So the framework has to help developers create that isolation deliberately.

This is why execution isolation is foundational in EvolvePHP

I could have treated persistent-worker support as something to add later.

Build the framework first.

Then create a RoadRunner integration.

Then add FrankenPHP.

Then fix whatever state leaks appear.

I don't want to do that.

By then, framework services may already have been designed around unsafe assumptions.

Instead, EvolvePHP starts with:

Application lifetime
Execution lifetime
Transient lifetime

and then asks every feature:

Which lifetime owns this state?

That question affects:

  • dependency injection,

  • authentication,

  • authorization,

  • transactions,

  • logging,

  • telemetry,

  • modules,

  • queues,

  • HTTP,

  • workers.

It is much easier to preserve isolation when the lifecycle is part of the original architecture.

The principle is simple

Persistent workers are not unsafe because they are persistent.

They are unsafe when applications behave as though the process is disposable even though it is not.

The solution is not to avoid reuse.

The solution is to make reuse conditional on isolation.

So the EvolvePHP rule is intentionally conservative:

One execution owns its state.

Cleanup must happen deterministically.

The next execution must not see the previous one.

If cleanup cannot prove the process is safe, do not reuse it.

That is the foundation.

Because the most dangerous persistent-worker bug is often not the request that fails.

It is the next request that succeeds using state it was never supposed to have.

And that is exactly why persistent PHP workers need execution isolation.

No comments:

Post a Comment