php
artykuł··zaktualizowano ·5 min czytania

Singleton w PHP: co znaczy „jedna instancja” pod PHP-FPM, Octane i PHPUnit

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

Klasyczny Singleton trzyma instancję we właściwości statycznej i wydaje ją przez getInstance(). Łączy leniwe tworzenie obiektu z globalnym dostępem do niego. Leniwe tworzenie bywa przydatne. Problemy opisane niżej biorą się z globalnego dostępu.

W PHP ważniejsze jest jednak, jak długo trwa ta „jedna”. Czas życia właściwości statycznej zależy od środowiska uruchomieniowego. Pod PHP-FPM silnik zeruje ją na końcu każdego requestu, mimo że proces działa dalej. W procesie, który obsługuje po kolei wiele requestów albo jobów, przetrwa do końca tego procesu.

Ile faktycznie żyje właściwość statyczna

ŚrodowiskoCzas życia self::$instance
PHP-FPM, mod_phpJeden request. Silnik czyści cały stan statyczny na końcu żądania.
Laravel Octane (Swoole, RoadRunner, FrankenPHP), queue:workJeden proces workera, przez wiele requestów lub jobów.
Skrypt CLI, komenda z cronaJedno uruchomienie.
PHPUnitCały przebieg testów w tym procesie, przez wszystkie przypadki testowe.

PHP-FPM: jedna instancja na request

Weźmy rate limiter napisany jako 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;
    }
}

Pod PHP-FPM każdy request zaczyna z pustą tablicą $hits. Licznik dochodzi do 1 i limit nigdy nie zadziała. Pod Octane liczyłby osobno w każdym workerze, więc faktyczny limit mógłby być nawet tyle razy wyższy, ile jest workerów (i serwerów), a requesty z jednego IP trafiałyby do workerów bez żadnego porządku. Do tego tablica $hits rosłaby w workerze bez końca, bo klucze z minionych minut nigdy nie znikają.

Wymaganie „100 requestów na minutę na klienta w całej aplikacji” dotyczy stanu wspólnego dla procesów i serwerów i żaden obiekt w pamięci procesu go nie spełni. Ten stan musi leżeć we współdzielonym cache store, np. w Redisie, Memcached albo w bazie. Laravel ma to gotowe: middleware throttle i fasada RateLimiter trzymają liczniki w cache store. Limit jest wspólny dla całej aplikacji tylko wtedy, gdy wszystkie serwery widzą ten sam store. Store array ani osobny file na każdym serwerze tego nie dają. Poniższy throttle:100,1 liczy zalogowanych po id użytkownika, a gości po IP. Middleware najpierw sprawdza licznik, a dopiero potem go zwiększa, więc przy równoległych requestach kilka może przejść ponad limit.

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

Workery długo żyjące: stan przecieka między requestami

W Octane i w queue workerze instancja statyczna przeżywa request i problem się odwraca. Wszystko, co Singleton zapamiętał z jednego requestu, widzi następny request obsłużony przez ten sam worker.

Singleton TenantContext zapisuje bieżącego tenanta przy pierwszym requeście. Pod PHP-FPM działa, bo instancja ginie razem z requestem. Pod Octane drugi request na tym samym workerze czyta kontekst pierwszego tenanta, jeśli nic go nie wyzeruje. Dokumentacja Octane ostrzega przed tym samym w przypadku singletonów z kontenera, które dostają w konstruktorze request albo sam kontener.

W kontenerze Laravela czas życia deklaruje się jawnie:

// AppServiceProvider::register()

// Jedna instancja na instancję aplikacji. Bezpieczne tylko dla obiektów niezmiennych i niezależnych od requestu.
$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));

// Jedna instancja na request albo job; Octane i queue worker czyszczą instancje scoped.
$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));

// Nowa instancja przy każdym resolve.
$this->app->bind(PaymentGateway::class, StripeGateway::class);

ExchangeRateTable, TenantContext i klasy gatewayów to przykładowe nazwy. Ważne, że czas życia siedzi w jednym miejscu i można go zmienić bez ruszania klas, które z tej zależności korzystają.

W moim SaaS-ie (multi-tenant, Laravel) kontekst firmy zrobiłem „fail-closed”: obiekt kontekstu firmy, trait na modelach i dwa jawne wejścia. Jedno ustawia kontekst przy rozwiązaniu zalogowanego użytkownika, drugie to osobny kontekst systemowy dla konsoli. Te konteksty się wykluczają, a ustawienie obu naraz rzuca wyjątek. Nie wyszło mi to za pierwszym razem. Dwa przeglądy kodu dały 12 ustaleń, w tym dwa krytyczne, których nie zauważyły ani testy, ani linty: tabela firm, czyli korzeń granicy, sama stała poza granicą, więc zalogowany użytkownik widział wszystkie firmy. Drugie nie dotyczy czasu życia obiektów, ale wyszło w tym samym przeglądzie: trustProxies(at: '*'), przez które limit prób logowania dało się obejść podrobionym nagłówkiem. Poprawiłem to na at: ['REMOTE_ADDR']. To chroni tylko wtedy, gdy aplikacja przyjmuje połączenia wyłącznie przez jedno reverse proxy, które samo ustawia nagłówek X-Forwarded-For. Jeśli da się do niej dojść z pominięciem proxy albo proxy jest kilka warstw, trzeba wpisać konkretne adresy proxy. Liczy się też wersja: od Laravela 13.20.0 (lipiec 2026) '*' oznacza zaufanie do każdego adresu, a w 11, 12 i w 13 do wydania 13.19 działało dokładnie tak samo jak REMOTE_ADDR.

PHPUnit: jedna instancja na cały zestaw testów

PHPUnit domyślnie uruchamia testy w jednym procesie. TestCase Laravela buduje dla każdego testu nową aplikację, więc singletony z kontenera się resetują, ale zwykłe właściwości statyczne już nie. Singleton zmieniony w jednym teście przenosi ten stan do następnego i wynik zaczyna zależeć od kolejności testów.

Zwykle obchodzi się to metodą resetInstance() wołaną w tearDown(), co działa, dopóki ktoś nie zapomni o niej w jednej klasie testowej. Pewniej jest przestać sięgać po obiekt globalny i wstrzyknąć zależność:

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'));
}

Kiedy statyczny Singleton da się obronić

Wzorzec się broni, gdy obiekt spełnia wszystkie poniższe warunki:

  • cały stan ustawia konstruktor i nic go potem nie zmienia,
  • stan nie zależy od requestu, użytkownika ani tenanta,
  • budowa obiektu jest na tyle droga, że ma sens robić ją raz na request albo raz na proces workera,
  • testy nigdy nie potrzebują innej instancji.

Pasuje do tego niezmienna konfiguracja czytana ze zmiennych środowiskowych:

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'),
        );
    }
}

Właściwości readonly blokują zmiany, a brak zmiennej wychodzi przy pierwszym wywołaniu zamiast gdzieś później, w kodzie, który nie ma z tym nic wspólnego. W aplikacji na Laravelu i tak zarejestrowałbym ten obiekt przez $this->app->singleton(), bo wtedy test może go podmienić przez $this->app->instance(). Wartości brałbym wtedy z config(), bo po php artisan config:cache Laravel nie wczytuje pliku .env i getenv() widzi tylko zmienne ustawione w systemie.

Do zapamiętania wyniku drogiego obliczenia Laravel od wersji 11 ma helper once(). Trzyma wynik domknięcia osobno dla każdego obiektu i miejsca wywołania, a Octane czyści go między requestami. Worker kolejki między jobami już nie. W metodzie samego joba nic to nie psuje, bo każdy job to nowy obiekt, ale once() w metodzie statycznej, w zwykłej funkcji albo w singletonie z kontenera trzyma wynik do końca procesu i opisany wyżej problem wraca.

koniec artykułu