php
article··updated ·6 min read

Singleton in PHP: what "one instance" means under PHP-FPM, Octane and PHPUnit

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

The classic Singleton stores an instance in a static property and hands it out through getInstance(). It bundles lazy construction with global access. Lazy construction is useful. The problems described below come from the global access.

In PHP there's a more important question, though: how long does "one" last? The lifetime of a static property depends on the runtime. Under PHP-FPM the engine resets it at the end of every request, even though the process keeps running. In a process that handles many requests or jobs in turn, it survives until that process ends.

What the static property actually survives

RuntimeLifetime of self::$instance
PHP-FPM, mod_phpOne request. The engine discards all static state at the end of the request.
Laravel Octane (Swoole, RoadRunner, FrankenPHP), queue:workOne worker process, across many requests or jobs.
CLI script, cron commandOne execution.
PHPUnitThe whole test run in that process, across all test cases.

PHP-FPM: one instance per request

Here's a rate limiter written as a Singleton:

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

    /** @var array<string, int> */
    private array $hits = [];

    public static function getInstance(): self
    {
        return self::$instance ??= new self();
    }

    public function allow(string $ip, int $limitPerMinute): bool
    {
        $key = $ip . ':' . intdiv(time(), 60);
        $this->hits[$key] = ($this->hits[$key] ?? 0) + 1;

        return $this->hits[$key] <= $limitPerMinute;
    }
}

Under PHP-FPM every request starts with an empty $hits array. The counter reaches 1, and the limit is never enforced. Under Octane it would count per worker, so the effective limit could be up to as many times higher as there are workers (and servers), with requests from one IP landing on workers in no predictable order. On top of that the $hits array would grow in the worker forever, because keys from past minutes are never removed.

"100 requests per minute per client across the application" is a requirement about state shared between processes and servers, and no in-process object can meet it. That state has to live in a shared cache store, e.g. Redis, Memcached or the database. Laravel already has this: the throttle middleware and the RateLimiter facade keep their counters in the cache store. The limit is shared across the application only if every server sees the same store. An array store or a separate file store on each server won't do that. The throttle:100,1 below counts logged-in users by user id and guests by IP. The middleware checks the counter first and increments it afterwards, so under concurrent requests a few may get through over the limit.

Route::middleware('throttle:100,1')->group(function () {
    Route::post('/api/scoring', ScoringController::class);
});

Long-running workers: state leaks between requests

In Octane or a queue worker the static instance survives the request, and the problem flips. Whatever request-specific data a Singleton captures is visible to the next request handled by the same worker.

Take a TenantContext singleton that stores the current tenant on the first request. Under PHP-FPM it works, because the instance dies with the request. Under Octane the second request on that worker reads the first tenant's context, unless something resets it. The Octane documentation warns about the same thing for container singletons that receive the request or the container in their constructor.

Laravel's container lets you declare lifetimes explicitly:

// AppServiceProvider::register()

// One instance per application instance. Safe only for immutable, request-independent objects.
$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));

// One instance per request or job; Octane and the queue worker flush scoped instances.
$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));

// New instance on every resolve.
$this->app->bind(PaymentGateway::class, StripeGateway::class);

ExchangeRateTable, TenantContext and the gateway classes are placeholders. What matters is that the lifetime sits in one place, and you can change it without touching the classes that use the dependency.

In my own SaaS (multi-tenant, Laravel) I made the company context fail-closed: a company context object, a trait on the models and two explicit entry points. One sets the context when the logged-in user is resolved, the other is a separate system context for console commands. The two exclude each other, and setting both at once throws an exception. I didn't get it right the first time. Two code reviews gave 12 findings, and two of them were critical and invisible to tests and linters: the companies table, the root of the tenant boundary, was itself outside the boundary, so a logged-in user could see all companies. The other has nothing to do with object lifetimes, but it came out of the same review: trustProxies(at: '*'), which let anyone bypass the login rate limit with a forged header. I changed it to at: ['REMOTE_ADDR']. That only protects you when the app accepts connections exclusively through a single reverse proxy that sets the X-Forwarded-For header itself. If the app can be reached around the proxy, or there are several proxy layers, list the actual proxy addresses. The version matters too: since Laravel 13.20.0 (July 2026) '*' trusts every address, while in 11, 12 and 13 up to 13.19 it behaved exactly like REMOTE_ADDR.

PHPUnit: one instance for the whole suite

By default PHPUnit runs test cases in one process. Laravel's TestCase builds a fresh application for every test, so container singletons get reset. Plain static properties don't. A Singleton modified in one test carries that state into the next one, and the result starts depending on execution order.

The usual workaround is a resetInstance() method called in tearDown(), which works until someone forgets it in one test class. It's safer to stop reaching for the global and inject the dependency:

interface Clock
{
    public function now(): DateTimeImmutable;
}

final class InvoiceDueDate
{
    public function __construct(private readonly Clock $clock) {}

    public function forTermDays(int $days): DateTimeImmutable
    {
        return $this->clock->now()->modify("+{$days} days");
    }
}
public function test_due_date_is_counted_from_today(): void
{
    $clock = new class implements Clock {
        public function now(): DateTimeImmutable
        {
            return new DateTimeImmutable('2024-10-15');
        }
    };

    $dueDate = (new InvoiceDueDate($clock))->forTermDays(14);

    $this->assertSame('2024-10-29', $dueDate->format('Y-m-d'));
}

When a static Singleton is defensible

It holds up when all of the following are true:

  • all state is set in the constructor and never modified afterwards,
  • the state does not depend on the request, the user or the tenant,
  • construction is expensive enough that doing it once per request or once per worker process matters,
  • tests never need a different instance.

Immutable configuration read from the environment fits:

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

    private function __construct(
        public readonly string $appEnv,
        public readonly string $databaseUrl,
    ) {}

    public static function get(): self
    {
        return self::$instance ??= new self(
            appEnv: getenv('APP_ENV') ?: 'production',
            databaseUrl: getenv('DATABASE_URL') ?: throw new RuntimeException('DATABASE_URL is not set'),
        );
    }
}

readonly properties prevent mutation, and a missing variable fails at the first call rather than somewhere later in unrelated code. In a Laravel application I'd still register the same object with $this->app->singleton(), because then a test can swap it with $this->app->instance(). I'd also take the values from config() there, because after php artisan config:cache Laravel doesn't load the .env file and getenv() only sees variables set in the system.

For memoizing an expensive computation, Laravel 11 and newer have the once() helper. It caches a closure's result per object and call site, and Octane flushes it between requests. A queue worker doesn't flush it between jobs. Inside a job's own method that's harmless, because every job is a new object, but once() in a static method, a plain function or a container singleton keeps its result for the life of the process and the problem described above comes back.

end of node