php runtime and server environment

Worker Mode And State Leakage

Worker mode keeps a PHP process alive while it handles multiple requests. That is useful for performance, but it means memory and object state can survive longer than a single request.

State leakage happens when data from one request is accidentally reused by a later request. In a web application, that can become a security bug, not just a performance bug.

A simple leak

Static properties are an easy way to demonstrate the problem:

PHP example
<?php

declare(strict_types=1);

final class RequestContext
{
    public static ?int $userId = null;
}

function handleRequest(?int $userId): void
{
    if ($userId !== null) {
        RequestContext::$userId = $userId;
    }

    echo 'Current user: ' . (RequestContext::$userId ?? 'guest') . PHP_EOL;
}

handleRequest(42);
handleRequest(null);

// Prints:
// Current user: 42
// Current user: 42

The second request should be a guest request, but it sees user 42 because the static property was not reset.

Safer request-scoped data

Prefer passing request-specific data through request objects, method arguments, or framework request containers that are reset each request.

PHP example
<?php

declare(strict_types=1);

final readonly class RequestData
{
    public function __construct(public ?int $userId)
    {
    }
}

function handleRequest(RequestData $request): void
{
    echo 'Current user: ' . ($request->userId ?? 'guest') . PHP_EOL;
}

handleRequest(new RequestData(42));
handleRequest(new RequestData(null));

// Prints:
// Current user: 42
// Current user: guest

This code makes the lifetime of the data obvious. Each request gets its own RequestData object.

Common leak sources

Watch for:

  • static properties that store users, tenants, locales, permissions, or request IDs
  • singleton services that keep mutable request data
  • global arrays used as temporary request storage
  • event listeners registered on every request and never removed
  • caches that mix tenant-specific data with global data
  • database transactions or connections left in a bad state
  • service containers that are not reset between requests

Not all static state is wrong. Immutable configuration, compiled routes, and shared service definitions can be fine. The danger is request-specific mutable data.

Resetting state

Some runtimes and frameworks provide lifecycle hooks to reset state after each request. Use them deliberately.

PHP example
<?php

declare(strict_types=1);

final class TenantContext
{
    private static ?string $tenantId = null;

    public static function set(?string $tenantId): void
    {
        self::$tenantId = $tenantId;
    }

    public static function reset(): void
    {
        self::$tenantId = null;
    }
}

TenantContext::set('tenant-a');
TenantContext::reset();

echo 'Tenant reset' . PHP_EOL;

// Prints:
// Tenant reset

Reset hooks are a fallback, not a licence to put request state anywhere. The cleaner design is still to keep request data request-scoped.

Testing for leakage

A useful test sends two requests through the same worker:

  1. Request A logs in or sets tenant-specific state.
  2. Request B is anonymous or uses a different tenant.
  3. Request B must not see Request A's user, tenant, locale, permissions, cart, or headers.

If your test suite boots a fresh application for every test, it may miss worker-mode bugs. You need at least some tests or manual checks that exercise multiple requests through the same worker process.

What you should be able to do

After this lesson, you should be able to identify request-specific state, explain why static properties and mutable singletons are risky in worker mode, design safer request-scoped data flow, and test two sequential requests for leakage.

Practice

Task: Find And Fix A State Leak

Create a small PHP script that shows request state leaking across two handled requests, then rewrite it so each request is isolated.

Requirements

  • Show a broken example using a static property or mutable singleton.
  • The first request should set a user or tenant value.
  • The second request should be anonymous or use a different value.
  • Show the corrected version using request-scoped data or an explicit reset.
  • Add a short note explaining how you would test this in a real worker runtime.

Check your work

The output should make the leak visible before the fix and show the correct request isolation after the fix.

Show solution
PHP example
<?php

declare(strict_types=1);

final class CurrentUser
{
    public static ?int $id = null;
}

function brokenHandleRequest(?int $userId): void
{
    if ($userId !== null) {
        CurrentUser::$id = $userId;
    }

    echo 'Broken current user: ' . (CurrentUser::$id ?? 'guest') . PHP_EOL;
}

brokenHandleRequest(42);
brokenHandleRequest(null);

// Prints:
// Broken current user: 42
// Broken current user: 42

The second request should be a guest request, but it reuses the previous user's state.

A safer version keeps the value scoped to the request:

PHP example
<?php

declare(strict_types=1);

final readonly class RequestData
{
    public function __construct(public ?int $userId)
    {
    }
}

function safeHandleRequest(RequestData $request): void
{
    echo 'Safe current user: ' . ($request->userId ?? 'guest') . PHP_EOL;
}

safeHandleRequest(new RequestData(42));
safeHandleRequest(new RequestData(null));

// Prints:
// Safe current user: 42
// Safe current user: guest

In a real worker runtime, test this by sending two requests through the same worker process: one authenticated request and one anonymous request. The second response must not contain the first user's ID, tenant, permissions, locale, cart, or other request-specific state.