Singleton — thread safety, testing problems, Laravel's singletons
Concept
The Singleton pattern ensures that a class has exactly one instance throughout the application's lifetime, and provides a global access point to that instance. It is the most recognized — and most controversial — design pattern in object-oriented programming.
The classic use cases for a true Singleton: a configuration registry (only one set of config values exists), a connection pool manager (one pool per database), a logging system (one log target per channel), or a service container itself (Laravel's Application instance). The pattern makes sense when a second instance would be logically wrong or wasteful.
The controversy comes from testing and hidden dependencies. A Singleton is a global shared state. When class A and class B both access Logger::getInstance(), they share state without any explicit declaration of that dependency. This makes tests unpredictable (one test's side effects bleed into another), makes the code harder to reason about (any call site can modify the shared state), and couples code to a specific concrete implementation.
How PHP Singletons differ from Java/C# Singletons: PHP is share-nothing by default — each request gets its own process (or FPM worker), so a Singleton in PHP is only a singleton within one request. It is not globally shared across all requests. This makes PHP Singletons less dangerous than server-side Singletons in long-running processes, but the testing problems remain.
Laravel's approach: Laravel's service container manages singletons through $this->app->singleton(). This is the recommended way to get Singleton behavior in Laravel — you get one instance per container (one per request in standard FPM), but it is still injectable and replaceable for testing. This is far superior to the traditional static-getInstance() Singleton.
Code Example
<?php
declare(strict_types=1);
// CLASSIC SINGLETON IMPLEMENTATION (for understanding only — prefer Laravel's container)
final class ConfigurationRegistry
{
private static ?self $instance = null;
private array $settings = [];
// Private constructor — prevents direct instantiation
private function __construct() {}
// Prevent cloning
private function __clone() {}
// Prevent unserialization
public function __wakeup(): never
{
throw new \RuntimeException('Cannot unserialize a singleton');
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function set(string $key, mixed $value): void
{
$this->settings[$key] = $value;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->settings[$key] ?? $default;
}
}
// Usage (problematic in tests)
ConfigurationRegistry::getInstance()->set('app_name', 'Zira');
$name = ConfigurationRegistry::getInstance()->get('app_name');
// BETTER APPROACH: Laravel container-managed singleton
// In AppServiceProvider:
$this->app->singleton(DatabaseConnectionPool::class, function (Application $app): DatabaseConnectionPool {
return new DatabaseConnectionPool(
host: config('database.connections.mysql.host'),
maxConns: config('database.pool.max_connections', 10),
);
});
// Now any class can inject it via constructor — testable, swappable
final class QueryExecutor
{
public function __construct(
// Laravel resolves this as a singleton — same instance every time
private readonly DatabaseConnectionPool $pool
) {}
public function execute(string $sql, array $bindings = []): array
{
$connection = $this->pool->acquire();
try {
return $connection->query($sql, $bindings);
} finally {
$this->pool->release($connection);
}
}
}
// TESTING PROBLEM with classic Singleton:
class SomeTest extends TestCase
{
public function test_something(): void
{
// State from a previous test may still be in the Singleton!
// ConfigurationRegistry::getInstance() holds state between tests
// With Laravel's container singleton, you can reset:
$this->app->forgetInstance(DatabaseConnectionPool::class);
// Or bind a fresh fake:
$this->app->instance(DatabaseConnectionPool::class, new FakeConnectionPool());
}
}Interview Q&A
Q: What are the problems with the classic Singleton pattern and how does Laravel solve them?
Classic Singletons have three main problems: (1) hidden dependencies — callers do not declare in their constructor that they need the singleton; (2) testing difficulty — the singleton carries state across tests, and you cannot inject a mock without modifying the class; (3) tight coupling — every caller is coupled to MySingleton::getInstance(), making the concrete type impossible to swap. Laravel solves all three with $this->app->singleton(). The bound class is still resolved once per container lifecycle, giving you the performance and consistency benefit of a singleton. But it is accessed through constructor injection (declared dependency), it can be swapped in tests with $this->app->instance() or $this->app->bind(), and it depends on an interface rather than the concrete class.
Q: Is the Singleton pattern always bad?
No. It is overused and often applied for the wrong reasons (global convenience, not genuine single-instance requirement). When a second instance would be logically wrong — like two service containers existing simultaneously, or two copies of a connection pool that each think they own all connections — a Singleton is appropriate. The mistake is using it as a shortcut to avoid dependency injection, which leads to the hidden-dependency problem. The rule: if you find yourself using a Singleton because "it's easier to access globally than to pass around," that is a code smell. If you use it because "two instances would corrupt state or waste limited resources," it is appropriate.
Q: How do PHP Singletons differ from Singletons in a long-running application like Swoole or Octane?
In standard PHP-FPM, each request runs in an isolated worker process. A static instance in a Singleton is fresh for every request. In Laravel Octane (or Swoole/RoadRunner), the application boots once and handles many requests in the same process. A Singleton in this environment really is globally shared across requests — state from request A persists into request B. This requires careful attention: singletons that hold request-specific state (authenticated user, request object, per-request caches) will cause subtle cross-request contamination bugs. Laravel's Octane documentation specifically lists services that need to be re-registered as scoped bindings to prevent this.