php/global-state-mgmt@v4.0.0
article··16 min read

The Singleton trap: global state, PHP-FPM workers, and the pattern that aged poorly

#php #design-patterns #dependency-injection #architecture #testing #php-fpm

I have been in exactly two code reviews where a developer proposed a Singleton and was right to do so. I have been in perhaps forty where they were not. The pattern is not the problem. The problem is that "I only want one of these" sounds like the right motivation almost every time, and it almost never is.

This is a production retrospective. The code is real. The incidents happened.

The PHP-FPM lie

The most dangerous misconception about Singleton in PHP is that it gives you one instance per application. It does not. It gives you one instance per worker process. On a standard PHP-FPM pool of 32 workers, you have 32 singletons.

This is not a niche edge case. It is the default. Every PHP application under any meaningful load runs this way.

// You think you have this:
//   Application → Singleton → one instance
//
// You actually have this:
//   Request #1 → Worker #1 → Singleton::$instance (object A)
//   Request #2 → Worker #7 → Singleton::$instance (object B)
//   Request #3 → Worker #7 → Singleton::$instance (object B)  ← same as #2
//   Request #4 → Worker #1 → Singleton::$instance (object A)  ← same as #1

class RateLimiter
{
    private static ?self $instance = null;
    private array $buckets = [];  // mutable state: requests per IP per minute

    public static function getInstance(): static
    {
        if (static::$instance === null) {
            static::$instance = new static();
        }
        return static::$instance;
    }

    public function check(string $ip, int $limit): bool
    {
        $key = $ip . ':' . floor(time() / 60);
        $this->buckets[$key] = ($this->buckets[$key] ?? 0) + 1;
        return $this->buckets[$key] <= $limit;
    }
}

We ran this rate limiter in production for three weeks before noticing that our 100 requests-per-minute limit was enforced as roughly 100 / 32 = 3 requests per minute on any single worker, or not enforced at all when requests spread across workers. The in-memory bucket was never shared. Each worker accumulated its own counter, and the limits were meaningless.

The fix was not "use a better Singleton." The fix was Redis. The pattern was wrong for the requirement from the beginning. The requirement was "one limit per IP across the entire application" and that is an explicitly distributed constraint. No in-process pattern satisfies it.

What the pattern actually guarantees

Stripped to its essence, Singleton guarantees one instance per process per class per class-loading context. Nothing more. In PHP-FPM that means one per worker, in a CLI script one per execution, in a test suite running in a single process one across all tests, which is usually a disaster that surfaces hours later when someone runs the suite in a different order.

The getInstance() mechanism is essentially a lazy constructor with global access. The valuable guarantee is the lazy construction. The dangerous part is the global access. These two concerns are bundled together by the pattern, and most of the time you want one without the other.

When it is genuinely correct

There are three scenarios where I have seen Singleton used correctly in production PHP systems.

Immutable application configuration loaded once from the environment is the clearest case:

final class AppConfig
{
    private static ?self $instance = null;

    private function __construct(
        public readonly string $appEnv,
        public readonly string $dbDsn,
        public readonly int    $dbPoolSize,
        public readonly string $redisUrl,
    ) {}

    public static function load(): static
    {
        if (static::$instance !== null) {
            return static::$instance;
        }

        return static::$instance = new static(
            appEnv:      (string) ($_ENV['APP_ENV']       ?? 'production'),
            dbDsn:       (string) ($_ENV['DATABASE_URL']  ?? throw new \RuntimeException('DATABASE_URL not set')),
            dbPoolSize:  (int)    ($_ENV['DB_POOL_SIZE']  ?? 10),
            redisUrl:    (string) ($_ENV['REDIS_URL']     ?? throw new \RuntimeException('REDIS_URL not set')),
        );
    }
}

readonly properties make this safe: there is no mutable state to corrupt across requests in a long-running process. The validation on construction means misconfiguration fails immediately at boot rather than silently at runtime.

The IoC container itself is the second legitimate use. Every modern PHP framework (Laravel, Symfony, Laminas) has an IoC container that is effectively a Singleton. The container is created once, populated with bindings, and resolved throughout the request lifecycle. This is correct because the container is stateless between resolutions: it holds definitions, not instances of every bound class.

// Laravel's Application class extends Container and is bootstrapped once.
// $app is passed around, but it is the same object everywhere.
// What makes this acceptable: the container holds closures, not resolved objects.
// Resolution happens on demand, and resolved instances have their own lifetime rules.

$app->bind(PaymentGatewayInterface::class, StripeGateway::class);
// ↑ stores a binding definition — no side effects

$gateway = $app->make(PaymentGatewayInterface::class);
// ↑ resolves on demand — fresh instance by default

The third case is connection pool managers, but only the manager itself, not the connections. A pool manager that tracks available connections is legitimately a per-process singleton: there should be exactly one pool per worker, and it should live for the entire worker lifetime. The connections it manages need to be checked out and returned.

The testing catastrophe

The reason "Singleton is an antipattern" became conventional wisdom is almost entirely about tests. Static state persists across test cases in a single process. Every test that touches a Singleton leaks state into the next one.

class UserRepositoryTest extends TestCase
{
    public function testFindByEmail(): void
    {
        // Database::getInstance() opens a real connection in __construct.
        // If the previous test left a transaction open, this one
        // will deadlock waiting for a lock that was never released.
        $repo = new UserRepository(Database::getInstance());
        $user = $repo->findByEmail('[email protected]');
        $this->assertNotNull($user);
    }
}

The standard workaround (Database::resetInstance() in tearDown()) is worse than the disease. You are now testing the reset behavior, and forgetting one teardown corrupts the rest of the suite in ways that are time-consuming to diagnose. I have spent more than one Friday afternoon on exactly this.

The structural fix is dependency injection with interfaces:

interface DatabaseConnectionInterface
{
    public function query(string $sql, array $params = []): array;
    public function execute(string $sql, array $params = []): int;
    public function beginTransaction(): void;
    public function commit(): void;
    public function rollback(): void;
}

class UserRepository
{
    public function __construct(
        private readonly DatabaseConnectionInterface $db
    ) {}

    public function findByEmail(string $email): ?User
    {
        $rows = $this->db->query(
            'SELECT * FROM users WHERE email = ? LIMIT 1',
            [$email]
        );
        return $rows ? User::fromRow($rows[0]) : null;
    }
}

Now the test injects a mock. The real connection is wired by the container. The test never touches global state.

class UserRepositoryTest extends TestCase
{
    public function testFindByEmail(): void
    {
        $db = $this->createMock(DatabaseConnectionInterface::class);
        $db->method('query')
           ->with('SELECT * FROM users WHERE email = ? LIMIT 1', ['[email protected]'])
           ->willReturn([['id' => 1, 'email' => '[email protected]', 'name' => 'Alice']]);

        $repo = new UserRepository($db);
        $user = $repo->findByEmail('[email protected]');

        $this->assertSame('[email protected]', $user->email);
    }
}

No static state. No teardown. No cross-test contamination. The test is faster, deterministic, and tests exactly one thing.

The DI container does what you wanted Singleton to do

Developers reach for Singleton for one of three reasons: they want lazy initialisation and do not want to pay for the object until it is needed, they want shared state so everyone reads the same config or talks to the same pool, or they want convenience and do not want to pass the object everywhere. A DI container with scoped lifetimes addresses all three without the global state problem:

// Register as singleton-scoped: instantiated once per container lifetime
$container->singleton(AppConfig::class, fn() => AppConfig::load());

// Register as transient: fresh instance on every resolve
$container->bind(PaymentGatewayInterface::class, StripeGateway::class);

// Register as request-scoped (in a long-running process like Swoole/RoadRunner):
// one instance per incoming request, released after the request ends
$container->scoped(UserSession::class, fn($c) => new UserSession(
    $c->make(RequestInterface::class)
));

The container is the one legitimate application-wide singleton. Everything else gets its lifetime managed by the container. This is not a semantic trick, it changes the operational properties of the system. You can swap implementations for tests, reset scoped state between requests in long-running processes, and inspect the dependency graph without reading every class.

The pattern I actually use

For objects that genuinely need one-per-process existence, I use a once() helper rather than a Singleton class:

function once(string $key, callable $factory): mixed
{
    static $store = [];
    return $store[$key] ??= $factory();
}

// Usage: lazily initialised, shared within a process, no class pollution
$config = once(AppConfig::class, fn() => AppConfig::load());

It is sixty characters. It survives code review. It does not spread private static $instance across the codebase. And in a test you can clear the static store entirely with a single reset function rather than hunting down resetInstance() methods across twenty classes.

What I watch for in code review

A Singleton is a yellow flag, not a red one. My first question is always: what is the lifetime of the mutable state this object holds?

If the answer is "it only holds configuration values set at construction time, never modified after", the pattern is defensible. If the answer is "it accumulates state across requests, counters, caches, buffers", that is the PHP-FPM trap. The fix is either Redis/Memcached for state that needs to survive beyond a single worker, or a scoped lifetime managed by the container for state that should reset per request.

The pattern is not evil. Global mutable state is evil. Singleton makes global mutable state easy to introduce and hard to notice until it causes an incident on a Friday afternoon.

end of node