Singleton Pattern in PHP: Refactoring Global State the Right Way

In many legacy PHP codebases, global state sneaks in quietly.
A config.php file is included everywhere.
A static helper class grows until it becomes a god object.
A database connection lives in a global variable “just for now”.

At first, it feels convenient.
Later, it becomes untestable, unpredictable, and painful to change.

Let’s look at how global state often appears in PHP projects, how developers usually try to “fix” it, and how refactoring toward a Singleton can help — but also why Singleton is frequently misused.

Previously article in this category: https://codecraftdiary.com/2026/02/07/builder-pattern-in-php/


A typical example:

// config.php
return [
    'db_host' => 'localhost',
    'db_user' => 'root',
    'db_pass' => 'secret',
];
PHP
// bootstrap.php
$config = require 'config.php';
PHP
// UserRepository.php
class UserRepository
{
    public function find(int $id): array
    {
        global $config;        $conn = new PDO(
            'mysql:host=' . $config['db_host'],
            $config['db_user'],
            $config['db_pass']
        );        return $conn->query('SELECT * FROM users WHERE id = ' . $id)->fetch();
    }
}
PHP

This code “works”.
It also hides a dependency.

UserRepository depends on configuration and a database connection, but nothing in its API communicates that. The dependency is invisible and implicit.

This leads to:

  • unpredictable behavior in tests
  • coupling between unrelated parts of the system
  • accidental mutation of shared state
  • code that is hard to reason about

This is a classic code smell: hidden dependencies via global state.


The next evolutionary step in many PHP projects looks like this:

class Config
{
    private static array $data;    public static function load(): void
    {
        self::$data = require 'config.php';
    }    public static function get(string $key): mixed
    {
        return self::$data[$key];
    }
}
PHP
class UserRepository
{
    public function find(int $id): array
    {
        $conn = new PDO(
            'mysql:host=' . Config::get('db_host'),
            Config::get('db_user'),
            Config::get('db_pass')
        );        return $conn->query('SELECT * FROM users WHERE id = ' . $id)->fetch();
    }
}
PHP

This looks cleaner:

  • no global
  • no messy includes
  • centralized config access

But architecturally, nothing really improved.

This is still:

  • global state
  • hidden dependency
  • impossible to replace in tests
  • tightly coupled to a static API

We replaced global with a static singleton-like façade.

This is not refactoring.
This is just changing syntax.


Let’s refactor this step by step into a proper Singleton.

final class Config
{
    private static ?Config $instance = null;
    private array $data;    private function __construct()
    {
        $this->data = require 'config.php';
    }    public static function getInstance(): Config
    {
        if (self::$instance === null) {
            self::$instance = new Config();
        }        return self::$instance;
    }    public function get(string $key): mixed
    {
        return $this->data[$key] ?? null;
    }
}
PHP

Usage:

class UserRepository
{
    public function find(int $id): array
    {
        $config = Config::getInstance();        $conn = new PDO(
            'mysql:host=' . $config->get('db_host'),
            $config->get('db_user'),
            $config->get('db_pass')
        );        return $conn->query('SELECT * FROM users WHERE id = ' . $id)->fetch();
    }
}
PHP

This is now a “proper” Singleton:

  • private constructor
  • lazy initialization
  • controlled access point

We improved:

  • structure
  • encapsulation
  • testability (slightly)

But we did not fix the core design issue.

The dependency is still hidden.


A Singleton is not dependency injection.
It is global state with better manners.

You still cannot:

  • pass different configurations in tests
  • swap implementations easily
  • reason locally about dependencies

From an architectural perspective, UserRepository still depends on Config, but that dependency is invisible in its constructor or method signature.

This creates tight coupling and invisible control flow.

The code is cleaner — but not better designed.


Singleton is not evil by definition.
It is evil when used as a default container for everything.

Legitimate use cases in PHP:

1. Immutable Application Configuration

final class AppConfig
{
    private static ?AppConfig $instance = null;
    private array $values;    private function __construct(array $values)
    {
        $this->values = $values;
    }    public static function boot(array $values): void
    {
        self::$instance = new self($values);
    }    public static function getInstance(): AppConfig
    {
        return self::$instance;
    }    public function get(string $key): mixed
    {
        return $this->values[$key] ?? null;
    }
}
PHP

Bootstrapped once, read-only afterwards.

2. Logger

A logger is often a cross-cutting concern:

  • shared
  • stateless
  • global by nature

3. Feature Flags / Environment Context

If the state is:

  • immutable
  • read-only
  • environment-level

Singleton can be acceptable.


Singleton becomes a problem when:

  • it contains mutable domain state
  • it replaces proper dependency injection
  • it hides real architectural boundaries
  • it becomes a service locator
  • business logic starts living inside it

If your Singleton has:

  • 20 methods
  • multiple responsibilities
  • domain logic

…you created a god object with a private constructor.


Often, Singleton should be treated as a transitional refactoring step, not a final architecture.

Better end state:

class UserRepository
{
    public function __construct(
        private PDO $connection
    ) {}    public function find(int $id): array
    {
        return $this->connection
            ->query('SELECT * FROM users WHERE id = ' . $id)
            ->fetch();
    }
}
PHP

And composition happens at the boundary of the application:

$config = Config::getInstance();$pdo = new PDO(
    'mysql:host=' . $config->get('db_host'),
    $config->get('db_user'),
    $config->get('db_pass')
);$repo = new UserRepository($pdo);
PHP

Now:

  • dependencies are explicit
  • testing is trivial
  • coupling is controlled
  • architecture is visible

Singleton is not a goal.
It is a compromise.

It can be a useful refactoring step when removing global state from a legacy PHP codebase.
It can make dependencies slightly more explicit.
It can improve encapsulation.

But if you stop there, you only replaced chaos with a nicer-looking global variable.

Good design does not start with patterns.
It starts with making dependencies explicit and pushing composition to the edges of your system.

Singleton is a tool.
Use it deliberately — and sparingly.

Leave a Reply

Your email address will not be published. Required fields are marked *