Eliminating Global Variables and Singletons in PHP
AI generated
<?php
8.4
PHP · Legacy · Dependency Injection · Refactoring
Eliminating Global Variables and Singletons in PHP
Dependency injection as the structural way out

Global variables and singletons feel convenient, yet they make PHP code hard to test, hard to parallelize and unpredictable in large projects. This article shows how to replace Singleton::getInstance(), $GLOBALS and static registries with dependency injection step by step, without rebuilding the whole application at once.

18 min read Singletons · $GLOBALS · DI container · Registry PHP 8.x · Legacy migration

1. Why global variables and singletons become a problem

Global variables and singletons solve a real problem at first glance: a database handle, a configuration object or a logger should be reachable from anywhere in the code without threading it through ten method calls. That very convenience is the core of the problem. As soon as an object is reachable from every point in the system, every class implicitly depends on that shared state without the dependency ever showing up in a constructor or a method signature. This is called hidden coupling, and it is the main reason grown PHP projects eventually become unmanageable.

In practice the problem looks like this: a developer changes the internal state of a singleton in module A, and three weeks later a seemingly unrelated test in module C breaks because both share the same global state. Debugging turns into detective work because the origin of a bug is no longer visible at the place where it manifests. In long lived PHP projects with several generations of developers, such singletons accumulate into a web of invisible dependencies that makes every change risky.

The way out is not "no shared objects anymore" but "shared objects passed explicitly." Dependency injection turns a hidden global dependency into a visible, constructor declared dependency. That is the thread running through this article: removing global variables and singletons from the code not by banning them, but by structurally replacing them.

2. The anatomy of a typical singleton

Almost every grown PHP codebase contains a class somewhere that follows this pattern: a private constructor, a static instance variable and a static getInstance() method that creates an object on first call and returns the same one afterward. The pattern guarantees that there is only one instance in the whole process, for example for a database connection or a configuration object. That guarantee is rarely actually required, it is mostly used out of convenience because nobody wants to pass the instance around.

The following example shows a typical singleton as found in many legacy projects, including the two symptoms that make it problematic: the static getInstance() method and the direct access to it, buried deep inside business logic.


<?php

declare(strict_types=1);

// Classic singleton anti-pattern found in legacy PHP codebases
final class Database
{
    private static ?Database $instance = null;
    private \PDO $connection;

    // Private constructor prevents direct instantiation
    private function __construct()
    {
        $this->connection = new \PDO(
            'mysql:host=localhost;dbname=shop',
            'app_user',
            getenv('DB_PASSWORD') ?: ''
        );
    }

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

    public function query(string $sql, array $params = []): \PDOStatement
    {
        $stmt = $this->connection->prepare($sql);
        $stmt->execute($params);
        return $stmt;
    }
}

// Usage buried deep inside business logic, hidden dependency
final class OrderRepository
{
    public function findById(int $id): array
    {
        // Global access point, invisible in the constructor signature
        $stmt = Database::getInstance()->query(
            'SELECT * FROM orders WHERE id = ?',
            [$id]
        );
        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
    }
}

The decisive flaw is not the existence of the Database class, it is that OrderRepository fetches it itself instead of receiving it. Anyone reading the constructor signature of OrderRepository sees no hint that it needs a database connection. This invisibility is the core of every singleton problem and the reason we tackle testability next.

3. Testability: why singletons make unit tests impossible

A unit test is supposed to verify a class isolated from its environment. For OrderRepository::findById() from the previous example that is not possible without spinning up a real database, because Database::getInstance() cannot be swapped from the outside. A test double, a mock or an in memory fake cannot be injected because the call is hard wired into the method. That is exactly the practical price paid for the convenience of singletons: every test touching this class turns into an integration test with real infrastructure.

With global variables via $GLOBALS or static class attributes the problem gets even worse, because state persists between test runs unless it is reset explicitly. A test that changes a global configuration can influence a completely different, later running test without any obvious connection between the two. Such tests become order dependent, a red flag in any test suite, because they fail sporadically in CI pipelines that run tests in parallel.

The solution is not to build more elaborate tests, it is to design the dependency so it can be swapped. As soon as a class receives its dependencies through the constructor, a test can pass a fake object without touching the production class at all. That is the real benefit of dependency injection: not less code, but swappable dependencies at clearly defined places.

4. Dependency injection as a structural replacement

Dependency injection is not complicated at its core: a class receives its dependencies from the outside instead of fetching them itself. Instead of calling Database::getInstance() inside a method, the constructor of OrderRepository accepts a PDO instance or an interface. That removes the hidden dependency from the method body and turns it into a visible, typed constructor parameter that every caller must satisfy.

The following example shows the same business logic as before, this time with constructor injection instead of singleton access. The class is now fully testable: a test can swap the PDO object for an in memory SQLite connection or pass a plain mock object without changing the production class.


<?php

declare(strict_types=1);

interface OrderRepositoryInterface
{
    public function findById(int $id): array;
}

// Constructor Property Promotion keeps the dependency explicit and typed
final class OrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private readonly \PDO $connection,
    ) {
    }

    public function findById(int $id): array
    {
        $stmt = $this->connection->prepare(
            'SELECT * FROM orders WHERE id = ?'
        );
        $stmt->execute([$id]);
        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
    }
}

// Composition happens once, at the boundary of the application
$pdo = new \PDO(
    'mysql:host=localhost;dbname=shop',
    'app_user',
    getenv('DB_PASSWORD') ?: ''
);
$repository = new OrderRepository($pdo);

// Unit test can now inject a fake or in-memory PDO without touching
// the production class at all
$testPdo = new \PDO('sqlite::memory:');
$testRepository = new OrderRepository($testPdo);

The essential difference is not in the line count, it is in visibility. Anyone reading the constructor signature of OrderRepository immediately knows what the class needs, without searching through the entire method body. That visibility is the real value of dependency injection over global variables and singletons, regardless of whether a container is used or not.

5. Introducing a DI container step by step

Once dozens of classes receive dependencies through constructors, manual wiring becomes tedious, because any class that itself has dependencies must again be instantiated by hand. A DI container automates this wiring by analyzing constructor type hints through reflection and resolving the matching objects. Importantly, the container is a tool at the composition root of the application, not another global access point called from inside business classes.

The difference between a singleton and a DI container is subtle but decisive: a container is called at exactly one place, the entry point of the application, to build the object graph. After that, the created objects pass their dependencies along themselves through constructors, the container itself never appears in business logic again. If the container is instead called from inside business classes, the result is a so called service locator, which brings the same testability problems as a singleton, just under a new name.


<?php

declare(strict_types=1);

// Minimal reflection-based container, resolves constructor dependencies
final class Container
{
    /** @var array<string, object> */
    private array $instances = [];

    public function set(string $id, object $instance): void
    {
        $this->instances[$id] = $instance;
    }

    public function get(string $className): object
    {
        if (isset($this->instances[$className])) {
            return $this->instances[$className];
        }

        $reflection = new \ReflectionClass($className);
        $constructor = $reflection->getConstructor();

        if ($constructor === null) {
            return $reflection->newInstance();
        }

        $arguments = [];
        foreach ($constructor->getParameters() as $parameter) {
            $type = $parameter->getType();
            if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
                $arguments[] = $this->get($type->getName());
            }
        }

        $instance = $reflection->newInstanceArgs($arguments);
        $this->instances[$className] = $instance;
        return $instance;
    }
}

// Composition root: the ONLY place the container is called directly
$container = new Container();
$container->set(\PDO::class, new \PDO('mysql:host=localhost;dbname=shop', 'app_user'));
$repository = $container->get(OrderRepository::class);

This minimal implementation is meant for understanding the concept. In practice teams use mature containers such as PHP-DI or the Symfony DependencyInjection component, which additionally map interfaces to concrete implementations, inject configuration values and detect cycles. What stays the same is the principle: the container composes once, business classes never access it themselves.

6. Migration strategy: retiring getInstance() gradually

An existing system with dozens of singleton calls cannot be rebuilt in one step without destabilizing the application for days. The proven approach is a gradual migration following the strangler fig principle: new classes are written with dependency injection from the start, existing singletons keep working for now but also gain a constructor based variant, so both access paths can coexist until every caller has been migrated.

Concretely: the private constructor of the Database class becomes public, getInstance() remains a thin wrapper around an internally held instance for the time being, but every new or reworked class receives the dependency through its own constructor. Only once the last caller of getInstance() has been removed can the static method be deleted safely. This order, migrate consumers first, remove the source afterward, prevents big bang refactorings, which regularly fail in legacy projects.

A pragmatic intermediate step is a static analyzer such as PHPStan with a custom rule that flags every new call to getInstance(), so the team notices when new code accidentally reintroduces the old pattern. This keeps the migration measurable instead of relying on good intentions, and progress can be tracked as a shrinking number of getInstance() hits in code review.

7. Decoupling global functions and $GLOBALS

Besides classic singletons, old PHP projects often contain free functions that access $GLOBALS directly, or configuration values stored in global constants such as DB_HOST. This form of global variables is even harder to control than a singleton because it offers no type safety at all: $GLOBALS['config'] can hold any value of any type, and the compiler cannot detect any errors at development time.

The migration step here is a simple value object that encapsulates configuration in a type safe way, plus a transition phase in which an adapter redirects the old global access to the new object. That way existing code that has not been migrated yet keeps working, while new code already uses only the type safe object.


<?php

declare(strict_types=1);

// Typed replacement for scattered $GLOBALS['db_host'] style access
final class DatabaseConfig
{
    public function __construct(
        public readonly string $host,
        public readonly string $database,
        public readonly string $user,
        public readonly string $password,
    ) {
    }

    public static function fromEnvironment(): self
    {
        return new self(
            host: getenv('DB_HOST') ?: 'localhost',
            database: getenv('DB_NAME') ?: 'shop',
            user: getenv('DB_USER') ?: 'app_user',
            password: getenv('DB_PASSWORD') ?: '',
        );
    }
}

// Transition adapter keeps legacy $GLOBALS access working
// while new code already uses the typed object exclusively
$config = DatabaseConfig::fromEnvironment();
$GLOBALS['db_host'] = $config->host; // remove once all readers are migrated

This adapter layer is deliberately meant as a transitional solution, not a target architecture. Once every reader of $GLOBALS['db_host'] has moved to the injected DatabaseConfig object, the line assigning the global can be removed. A grep for $GLOBALS[ across the project directory provides an honest, steadily shrinking metric for migration progress.

8. The registry pattern as a controlled intermediate step

Not every application can move to full dependency injection immediately, especially when a framework such as an old CMS forces certain global access points. For this case, the registry pattern works as a deliberately chosen, documented compromise: a single, clearly named registry class holds the few objects that are genuinely shared, instead of every class defining its own singleton.

The difference from a classic singleton is that the registry does not take over object creation itself, it merely holds already constructed objects at a central place. That keeps creation logic testable and swappable, while the global access point remains a conscious, documented exception rather than spreading uncontrolled across dozens of classes. It is important to time box this compromise and mark it in the code as a transitional solution, so it does not turn into a permanent excuse.


<?php

declare(strict_types=1);

// Time-boxed compromise: a single, documented access point,
// not object creation itself — mark clearly as transitional
final class Registry
{
    /** @var array<string, object> */
    private static array $entries = [];

    public static function register(string $key, object $instance): void
    {
        if (isset(self::$entries[$key])) {
            throw new \LogicException("Registry entry '{$key}' already set");
        }
        self::$entries[$key] = $instance;
    }

    public static function get(string $key): object
    {
        return self::$entries[$key]
            ?? throw new \RuntimeException("Registry entry '{$key}' not found");
    }

    // TRANSITIONAL: framework forces global access at the request bootstrap.
    // TODO: remove once the legacy CMS bridge supports constructor injection.
    public static function reset(): void
    {
        self::$entries = [];
    }
}

// Bootstrap, composition root only — never called from business classes
Registry::register(\PDO::class, new \PDO('mysql:host=localhost;dbname=shop', 'app_user'));

9. Singleton vs. DI container vs. service locator compared

These three patterns are frequently confused in discussions, even though they differ significantly in their effect on testability and coupling. The following table contrasts the decisive differences.

Pattern Access from business code Testability Recommendation
Singleton Direct, everywhere in the code Poor, no swapping possible Tolerate only for already fully migrated legacy remnants
Service Locator Direct, hidden inside methods Poor, dependency stays invisible Avoid, it is a singleton under a different name
Registry (time boxed) Central, documented Medium, predictably swappable As a deliberate transition under framework constraints
DI Container Only at the composition root Excellent, constructor injection Target architecture for new and migrated code

The table makes clear that not every pattern with a global character is automatically bad. What matters is whether access happens at a single, documented place or spreads uncontrolled across the whole codebase. A DI container called exclusively at the composition root causes none of the testability problems that singletons and service locators bring.

Mironsoft

PHP legacy modernization and Magento development

Ready to remove singletons and global variables from your own code?

We analyze existing PHP codebases for hidden global dependencies and guide the step by step move to dependency injection without putting live operations at risk.

Code Audit

Systematically capture every singleton and $GLOBALS access in the project

Migration Plan

Strangler fig strategy with prioritized, low risk migration steps

DI Container

Introducing and wiring a production ready container

10. Summary

Global variables and singletons are not malicious constructs, they are convenient shortcuts whose cost only becomes visible as the codebase grows: poor testability, hidden coupling and hard to trace bugs. Dependency injection solves the structural problem by making dependencies visible in the constructor instead of hiding them behind static method calls. A DI container automates the wiring without becoming a new global access point itself, as long as it is called exclusively at the composition root.

Migrating existing systems does not succeed through a big bang, but through a gradual rebuild following the strangler fig principle: new classes get constructor injection immediately, existing getInstance() calls are retired consumer by consumer until the static method can be removed safely. A registry pattern can serve as a time boxed, documented compromise when a framework forces global access points. In the end, the codebase has every dependency visible, swappable and therefore testable.

Eliminating Global Variables and Singletons — Key Takeaways

Core Problem

Singletons and $GLOBALS create hidden coupling invisible in the constructor, making unit tests impossible.

Solution

Dependency injection makes dependencies explicit. A DI container automates wiring at the composition root.

Migration Path

Strangler fig principle: keep getInstance() running in parallel first, retire it consumer by consumer, remove it last.

Transitional Solution

Registry pattern as a time boxed, documented compromise when framework constraints prevent an immediate full migration.

11. FAQ: Eliminating Global Variables and Singletons

1Is a singleton always an anti pattern?
In most cases yes. Even in rare exceptions like pure registry access, DI is usually the better choice.
2Do I need a container right away?
No, first write new classes with constructor injection. The container later only automates the wiring.
3How to migrate without a big bang?
Strangler fig principle: getInstance() stays for now, new classes get constructor injection, the static method is removed last.
4DI container vs. service locator?
Container only acts at the composition root, service locator is called from inside business classes with the same problems as a singleton.
5How do I find all occurrences in the project?
Grep for getInstance( and $GLOBALS[, supplemented by custom PHPStan rules flagging new occurrences in review.
6Is the registry pattern allowed?
Acceptable as a time boxed, documented compromise, but inferior to a DI container as a permanent solution.
7How do I test not yet migrated code?
Inject a test instance into the singleton via set() before the test, a transitional hack until full migration.
8Does DI slow the application down?
No, the difference is negligible, reflection overhead can additionally be eliminated by caching metadata.
9What about global constants?
Move into a typed value object like DatabaseConfig, inject via constructor, keep the old constant as an adapter.
10Is this worth it for small projects?
Often not needed for very small, short lived scripts. As soon as the project grows or needs tests, DI pays off quickly.