Singleton w PHP: co znaczy „jedna instancja” pod PHP-FPM, Octane i PHPUnit
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
| Środowisko | Czas życia self::$instance |
|---|---|
| PHP-FPM, mod_php | Jeden request. Silnik czyści cały stan statyczny na końcu żądania. |
Laravel Octane (Swoole, RoadRunner, FrankenPHP), queue:work | Jeden proces workera, przez wiele requestów lub jobów. |
| Skrypt CLI, komenda z crona | Jedno uruchomienie. |
| PHPUnit | Cał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.