Globale Variablen und Singletons in PHP loswerden
AI generated
<?php
8.4
PHP · Legacy · Dependency Injection · Refactoring
Globale Variablen und Singletons in PHP loswerden
Dependency Injection als struktureller Ausweg

Globale Variablen und Singletons wirken bequem, machen PHP-Code aber schwer testbar, schwer parallelisierbar und in großen Projekten unberechenbar. Dieser Artikel zeigt, wie man Singleton::getInstance(), $GLOBALS und statische Registries schrittweise durch Dependency Injection ersetzt, ohne die Anwendung in einem Rutsch umzubauen.

18 Min. Lesezeit Singletons · $GLOBALS · DI-Container · Registry PHP 8.x · Legacy-Migration

1. Warum globale Variablen und Singletons zum Problem werden

Globale Variablen und Singletons lösen auf den ersten Blick ein reales Problem: Ein Datenbank-Handle, eine Konfiguration oder ein Logger sollen von überall im Code erreichbar sein, ohne sie durch zehn Methodenaufrufe hindurchzureichen. Genau diese Bequemlichkeit ist aber der Kern des Problems. Sobald ein Objekt von jeder Stelle im System aus erreichbar ist, wird jede Klasse implizit von diesem globalen Zustand abhängig, ohne dass diese Abhängigkeit im Konstruktor oder in der Signatur sichtbar wird. Man nennt das versteckte Kopplung, und sie ist der Hauptgrund, warum gewachsene PHP-Projekte irgendwann unübersichtlich werden.

In der Praxis äußert sich das Problem so: Ein Entwickler ändert den internen Zustand eines Singletons in Modul A, und drei Wochen später bricht ein scheinbar unabhängiger Test in Modul C, weil beide denselben globalen Zustand teilen. Debugging wird dadurch zur Detektivarbeit, weil der Ursprung eines Fehlers nicht mehr am Ort seiner Auswirkung sichtbar ist. Gerade in langlebigen PHP-Projekten mit mehreren Entwicklergenerationen summieren sich solche Singletons zu einem Netz aus unsichtbaren Abhängigkeiten, das jede Änderung riskant macht.

Der Ausweg heißt nicht "keine gemeinsamen Objekte mehr", sondern "gemeinsame Objekte explizit übergeben". Dependency Injection macht aus einer versteckten globalen Abhängigkeit eine sichtbare, im Konstruktor deklarierte Abhängigkeit. Das ist der rote Faden dieses Artikels: globale Variablen und Singletons nicht per Verbot, sondern per strukturellem Ersatz aus dem Code zu entfernen.

2. Die Anatomie eines typischen Singletons

Fast jeder gewachsene PHP-Codebestand enthält irgendwo eine Klasse nach diesem Muster: ein privater Konstruktor, eine statische Instanzvariable und eine statische getInstance()-Methode, die beim ersten Aufruf ein Objekt erzeugt und danach immer dasselbe zurückgibt. Das Muster garantiert, dass es im gesamten Prozess nur eine Instanz gibt, etwa für eine Datenbankverbindung oder ein Konfigurationsobjekt. Genau diese Garantie ist aber in den seltensten Fällen tatsächlich nötig, sie wird meist aus Bequemlichkeit eingesetzt, weil man die Instanz nicht durchreichen möchte.

Das folgende Beispiel zeigt einen typischen Singleton, wie er in vielen Legacy-Projekten zu finden ist, inklusive der beiden Symptome, die ihn problematisch machen: die statische getInstance()-Methode und der direkte Zugriff darauf, tief verschachtelt in fachlichem Code.


<?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) ?: [];
    }
}

Der entscheidende Fehler liegt nicht in der Existenz der Klasse Database, sondern darin, dass OrderRepository sie sich selbst besorgt, statt sie zu erhalten. Wer den Code der Klasse OrderRepository liest, sieht in der Konstruktorsignatur keinerlei Hinweis darauf, dass sie eine Datenbankverbindung benötigt. Diese Unsichtbarkeit ist der Kern jedes Singleton-Problems und der Grund, warum wir im nächsten Abschnitt bei der Testbarkeit ansetzen.

3. Testbarkeit: warum Singletons Unit-Tests unmöglich machen

Ein Unit-Test soll eine Klasse isoliert von ihrer Umgebung prüfen. Bei OrderRepository::findById() aus dem vorherigen Beispiel ist das aber nicht möglich, ohne eine echte Datenbank aufzubauen, denn Database::getInstance() lässt sich von außen nicht austauschen. Ein Test-Double, ein Mock oder ein In-Memory-Fake kann nicht eingeschleust werden, weil der Aufruf hart in die Methode einkompiliert ist. Genau das ist der praktische Preis, den man für die Bequemlichkeit von Singletons zahlt: Jeder Test, der diese Klasse berührt, wird zu einem Integrationstest mit echter Infrastruktur.

Bei globalen Variablen über $GLOBALS oder statische Klassenattribute verschärft sich das Problem noch, weil der Zustand zwischen Testläufen bestehen bleibt, sofern er nicht explizit zurückgesetzt wird. Ein Test, der eine globale Konfiguration ändert, kann dadurch einen völlig anderen, später laufenden Test beeinflussen, ohne dass eine offensichtliche Verbindung zwischen beiden besteht. Solche Tests werden reihenfolgeabhängig, ein Alarmsignal in jeder Testsuite, weil sie in CI-Pipelines mit paralleler Ausführung sporadisch fehlschlagen.

Die Lösung ist nicht, Tests aufwendiger zu bauen, sondern die Abhängigkeit so zu gestalten, dass sie austauschbar ist. Sobald eine Klasse ihre Abhängigkeiten über den Konstruktor erhält, kann ein Test ein Fake-Objekt übergeben, ohne die Produktionsklasse anzufassen. Das ist der eigentliche Gewinn von Dependency Injection: nicht weniger Code, sondern austauschbare Abhängigkeiten an klar definierten Stellen.

4. Dependency Injection als struktureller Ersatz

Dependency Injection bedeutet im Kern nichts Kompliziertes: Eine Klasse erhält ihre Abhängigkeiten von außen, statt sie sich selbst zu holen. Statt Database::getInstance() innerhalb der Methode aufzurufen, nimmt der Konstruktor von OrderRepository eine PDO-Instanz oder ein Interface entgegen. Damit verschwindet die versteckte Abhängigkeit aus dem Methodenkörper und wird zu einem sichtbaren, typisierten Konstruktorparameter, den jeder Aufrufer erfüllen muss.

Das folgende Beispiel zeigt dieselbe Fachlichkeit wie zuvor, diesmal aber mit Constructor Injection statt Singleton-Zugriff. Die Klasse ist dadurch vollständig testbar: Ein Test kann ein PDO-Objekt gegen SQLite im Speicher austauschen oder ein reines Mock-Objekt übergeben, ohne die Produktionsklasse zu verändern.


<?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);

Der wesentliche Unterschied liegt nicht in der Zeilenzahl, sondern in der Sichtbarkeit. Wer die Konstruktorsignatur von OrderRepository liest, weiß sofort, was die Klasse braucht, ohne den gesamten Methodenkörper zu durchsuchen. Genau diese Sichtbarkeit ist der eigentliche Mehrwert von Dependency Injection gegenüber globalen Variablen und Singletons, unabhängig davon, ob man dafür einen Container einsetzt oder nicht.

5. Einen DI-Container schrittweise einführen

Sobald Dutzende Klassen Abhängigkeiten über Konstruktoren erhalten, wird das manuelle Verdrahten mühsam, weil jede Klasse, die selbst Abhängigkeiten hat, wiederum manuell instanziiert werden muss. Ein DI-Container übernimmt diese Verdrahtung automatisch, indem er Konstruktor-Typehints per Reflection analysiert und die passenden Objekte auflöst. Wichtig ist dabei: Der Container ist ein Werkzeug an der Composition Root der Anwendung, nicht ein weiterer globaler Zugriffspunkt, der in Fachklassen aufgerufen wird.

Der Unterschied zwischen einem Singleton und einem DI-Container ist subtil, aber entscheidend: Ein Container wird an genau einer Stelle, dem Einstiegspunkt der Anwendung, aufgerufen, um den Objektgraphen aufzubauen. Danach reichen die erzeugten Objekte ihre Abhängigkeiten selbst über Konstruktoren weiter, der Container selbst taucht in der Fachlogik nicht mehr auf. Wird der Container dagegen aus Fachklassen heraus aufgerufen, entsteht ein sogenannter Service Locator, der dieselben Testbarkeitsprobleme wie ein Singleton mit sich bringt, nur unter neuem Namen.


<?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);

Diese minimale Implementierung dient dem Verständnis. In der Praxis nutzt man ausgereifte Container wie PHP-DI oder den Symfony DependencyInjection Container, die zusätzlich Interfaces auf konkrete Implementierungen mappen, Konfigurationswerte injizieren und Zyklen erkennen. Entscheidend bleibt aber das Prinzip: der Container komponiert einmalig, die Fachklassen greifen nie selbst darauf zu.

6. Migrationsstrategie: getInstance() schrittweise ablösen

Ein bestehendes System mit Dutzenden Singleton-Aufrufen lässt sich nicht in einem Schritt umbauen, ohne die Anwendung tagelang instabil zu machen. Der bewährte Weg ist die schrittweise Migration nach dem Strangler-Fig-Prinzip: Neue Klassen werden von Anfang an mit Dependency Injection geschrieben, bestehende Singletons bleiben zunächst funktionsfähig, erhalten aber zusätzlich eine Konstruktor-Variante, sodass beide Zugriffswege parallel existieren können, bis alle Aufrufer migriert sind.

Konkret bedeutet das: Der private Konstruktor der Database-Klasse wird public, getInstance() bleibt vorerst als dünner Wrapper um eine intern gehaltene Instanz bestehen, aber jede neue oder überarbeitete Klasse erhält die Abhängigkeit über den eigenen Konstruktor. Erst wenn der letzte Aufrufer von getInstance() entfernt wurde, kann die statische Methode gefahrlos gelöscht werden. Diese Reihenfolge, zuerst Konsumenten migrieren, danach die Quelle entfernen, verhindert Big-Bang-Refactorings, die in Legacy-Projekten regelmäßig scheitern.

Ein pragmatischer Zwischenschritt ist ein statischer Analyzer wie PHPStan, der mit einer benutzerdefinierten Regel jeden neuen Aufruf von getInstance() markiert, sodass das Team sieht, wenn versehentlich neuer Code das alte Muster übernimmt. So bleibt die Migration messbar, statt sich in gutem Willen zu erschöpfen, und der Fortschritt lässt sich als sinkende Zahl von getInstance()-Treffern im Code-Review verfolgen.

7. Globale Funktionen und $GLOBALS entkoppeln

Neben klassischen Singletons finden sich in alten PHP-Projekten oft freie Funktionen, die direkt auf $GLOBALS zugreifen, oder Konfigurationswerte, die in globalen Konstanten wie DB_HOST gespeichert sind. Diese Form der globalen Variablen ist noch schwerer zu kontrollieren als ein Singleton, weil sie keinerlei Typsicherheit bietet: $GLOBALS['config'] kann jeden Wert jedes Typs enthalten, und der Compiler kann keinerlei Fehler zur Entwicklungszeit erkennen.

Der Migrationsschritt hierfür ist ein einfaches Value Object, das die Konfiguration typsicher kapselt, sowie eine Übergangsphase, in der ein Adapter die alten globalen Zugriffe auf das neue Objekt umleitet. So bleibt bestehender Code, der noch nicht migriert wurde, lauffähig, während neuer Code bereits ausschließlich das typsichere Objekt nutzt.


<?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

Diese Adapterschicht ist bewusst als Übergangslösung gedacht, keine Zielarchitektur. Sobald alle Leser von $GLOBALS['db_host'] auf das injizierte DatabaseConfig-Objekt umgestellt wurden, kann die Zeile mit der globalen Zuweisung entfernt werden. Ein Grep nach $GLOBALS[ im Projektverzeichnis liefert dabei eine ehrliche, laufend sinkende Kennzahl für den Fortschritt der Migration.

8. Registry-Pattern als kontrollierter Zwischenschritt

Nicht jede Anwendung kann sofort vollständig auf Dependency Injection umgestellt werden, besonders wenn ein Framework wie ein altes CMS bestimmte globale Zugriffspunkte erzwingt. Für diesen Fall eignet sich das Registry-Pattern als bewusst gewählter, dokumentierter Kompromiss: Eine einzige, klar benannte Registry-Klasse hält die wenigen tatsächlich geteilten Objekte, statt dass jede Klasse ihren eigenen Singleton definiert.

Der Unterschied zu einem klassischen Singleton liegt darin, dass die Registry nicht die Objekterzeugung selbst übernimmt, sondern nur bereits fertig konstruierte Objekte an einer zentralen Stelle bereithält. Dadurch bleibt die Erzeugungslogik testbar und austauschbar, während der globale Zugriffspunkt als bewusste, dokumentierte Ausnahme bestehen bleibt, statt sich unkontrolliert über Dutzende Klassen zu verteilen. Wichtig ist, diesen Kompromiss zeitlich zu befristen und im Code als Übergangslösung zu kennzeichnen, damit er nicht zur dauerhaften Ausrede wird.


<?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 im Vergleich

Die drei Muster werden in Diskussionen häufig verwechselt, obwohl sie sich in ihren Auswirkungen auf Testbarkeit und Kopplung deutlich unterscheiden. Die folgende Tabelle stellt die entscheidenden Unterschiede gegenüber.

Muster Zugriff aus Fachcode Testbarkeit Empfehlung
Singleton Direkt, überall im Code Schlecht, kein Austausch möglich Nur für bereits vollständig migrierte Legacy-Reste tolerieren
Service Locator Direkt, versteckt in Methoden Schlecht, Abhängigkeit bleibt unsichtbar Vermeiden, ist ein Singleton mit anderem Namen
Registry (befristet) Zentral, dokumentiert Mittel, planbar austauschbar Als bewusster Übergang bei Framework-Zwang
DI-Container Nur an der Composition Root Sehr gut, Konstruktor-Injection Zielarchitektur für neuen und migrierten Code

Die Tabelle macht deutlich, dass nicht jedes Muster mit globalem Charakter automatisch schlecht ist. Entscheidend ist, ob der Zugriff an einer einzigen, dokumentierten Stelle stattfindet oder sich unkontrolliert über die gesamte Codebasis verteilt. Ein DI-Container, der ausschließlich an der Composition Root aufgerufen wird, verursacht keine der Testbarkeitsprobleme, die Singletons und Service Locators mit sich bringen.

Mironsoft

PHP-Legacy-Modernisierung und Magento-Entwicklung

Singletons und globale Variablen im eigenen Code loswerden?

Wir analysieren bestehende PHP-Codebasen auf versteckte globale Abhängigkeiten und begleiten den schrittweisen Umbau auf Dependency Injection, ohne den laufenden Betrieb zu gefährden.

Code-Audit

Alle Singleton- und $GLOBALS-Zugriffe im Projekt systematisch erfassen

Migrationsplan

Strangler-Fig-Strategie mit priorisierten, risikoarmen Migrationsschritten

DI-Container

Einführung und Verdrahtung eines produktionsreifen Containers

10. Zusammenfassung

Globale Variablen und Singletons sind keine bösartigen Konstrukte, sondern bequeme Abkürzungen, deren Preis erst mit wachsender Codebasis sichtbar wird: schlechte Testbarkeit, versteckte Kopplung und schwer nachvollziehbare Fehler. Dependency Injection löst das strukturelle Problem, indem Abhängigkeiten im Konstruktor sichtbar werden, statt sich hinter statischen Methodenaufrufen zu verstecken. Ein DI-Container automatisiert die Verdrahtung, ohne selbst zu einem neuen globalen Zugriffspunkt zu werden, solange er ausschließlich an der Composition Root aufgerufen wird.

Die Migration bestehender Systeme gelingt nicht per Big Bang, sondern über einen schrittweisen Umbau nach dem Strangler-Fig-Prinzip: Neue Klassen erhalten sofort Constructor Injection, bestehende getInstance()-Aufrufe werden konsumentenweise abgelöst, bis die statische Methode gefahrlos entfernt werden kann. Ein Registry-Pattern kann dabei als befristeter, dokumentierter Kompromiss dienen, wenn ein Framework globale Zugriffspunkte erzwingt. Am Ende steht eine Codebasis, in der jede Abhängigkeit sichtbar, austauschbar und damit testbar ist.

Globale Variablen und Singletons loswerden — Das Wichtigste auf einen Blick

Kernproblem

Singletons und $GLOBALS erzeugen versteckte Kopplung, die im Konstruktor nicht sichtbar ist und Unit-Tests unmöglich macht.

Lösung

Dependency Injection macht Abhängigkeiten explizit. Ein DI-Container automatisiert die Verdrahtung an der Composition Root.

Migrationspfad

Strangler-Fig-Prinzip: getInstance() erst parallel betreiben, konsumentenweise ablösen, zuletzt entfernen.

Übergangslösung

Registry-Pattern als befristeter, dokumentierter Kompromiss, wenn Framework-Zwang eine sofortige Vollmigration verhindert.

11. FAQ: Globale Variablen und Singletons loswerden

1Ist ein Singleton immer ein Anti-Pattern?
In den allermeisten Fällen ja. Selbst bei seltenen Ausnahmen wie reinen Registry-Zugriffen ist DI meist die bessere Wahl.
2Muss ich sofort einen Container einführen?
Nein, erst neue Klassen mit Constructor Injection schreiben. Der Container automatisiert später nur die Verdrahtung.
3Wie migriere ich ohne Big Bang?
Strangler-Fig-Prinzip: getInstance() bleibt vorerst, neue Klassen erhalten Constructor Injection, zuletzt wird die statische Methode entfernt.
4DI-Container vs. Service Locator?
Container wirkt nur an der Composition Root, Service Locator wird aus Fachklassen heraus aufgerufen und hat dieselben Probleme wie ein Singleton.
5Wie finde ich alle Vorkommen im Projekt?
Grep nach getInstance( und $GLOBALS[, ergänzt durch eigene PHPStan-Regeln für neue Vorkommen im Review.
6Ist das Registry-Pattern erlaubt?
Als befristeter, dokumentierter Kompromiss vertretbar, als Dauerlösung dem DI-Container aber unterlegen.
7Wie teste ich noch nicht migrierten Code?
Vor dem Test eine Test-Instanz per set() in den Singleton injizieren, als Übergangs-Hack bis zur vollständigen Migration.
8Verlangsamt DI die Anwendung?
Nein, der Unterschied ist vernachlässigbar, Reflection-Overhead lässt sich zusätzlich durch Metadaten-Caching eliminieren.
9Was tue ich mit globalen Konstanten?
In ein typisiertes Value Object wie DatabaseConfig überführen, per Konstruktor injizieren, alte Konstante als Adapter belassen.
10Lohnt sich das bei kleinen Projekten?
Bei sehr kleinen, kurzlebigen Skripten oft nicht nötig. Sobald das Projekt wächst oder Tests braucht, zahlt sich DI schnell aus.