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
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
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
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:
- Request A logs in or sets tenant-specific state.
- Request B is anonymous or uses a different tenant.
- 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
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
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.