Singleton Pattern in Magento 2: Wo es steckt und warum du es vermeidest
· Lesezeit: ca. 13 Minuten · Kategorie: Magento 2 · Design Patterns
Singleton Pattern
in Magento 2 verstehen
Magentos DI-Container erstellt standardmäßig Shared Objects – das ist Singleton-Verhalten. Wo es nützlich ist, wo es Probleme verursacht und wie du Legacy-Singletons sauber refaktorierst.
Das unsichtbare Singleton
Das Singleton Pattern gilt in modernem PHP als Anti-Pattern – und trotzdem ist es tief in Magento 2 verankert. Nicht als explizite getInstance()-Implementierung wie in Magento 1, sondern als Standardverhalten des DI-Containers: Jede Klasse, die über Constructor Injection bezogen wird, ist per Default ein Shared Object – also ein Singleton für die Lebenszeit des Requests.
Das ist oft das Richtige. Aber es ist auch eine Quelle von subtilen Bugs, Testproblemen und unerwünschten Seiteneffekten. Wer Magento-Code schreibt, ohne das zu verstehen, produziert Code der in Produktion anders läuft als im Test.
Dieser Deep Dive erklärt: Wie Magentos Singleton-Mechanismus funktioniert, wo er nützlich ist, wo er gefährlich wird – und wie du Singletons erkennst und ersetzt.
- 1. Das GoF Singleton Pattern
- 2. Shared vs. Non-shared Objects: Magentos Singleton-Mechanismus
- 3. ObjectManager und Singletons
- 4. Versteckte Singletons: Wo sie wirklich stecken
- 5. Warum Singletons Probleme verursachen
- 6. Singleton vs. Testbarkeit: Konkrete Probleme
- 7. Non-shared Objects: Wann neue Instanzen nötig sind
- 8. Factory als Lösung: Neue Instanzen on demand
- 9. Legacy-Singletons refaktorieren
- 10. Zusammenfassung
- 11. FAQ
1. Das GoF Singleton Pattern
Das Singleton Pattern aus dem GoF-Buch (1994) stellt sicher, dass von einer Klasse genau eine Instanz existiert und diese global zugänglich ist. Die klassische Implementierung in PHP:
<?php
// Klassisches Singleton (PHP Anti-Pattern):
class Registry
{
private static ?self $instance = null;
private array $data = [];
// Private constructor: kein direktes new Registry()
private function __construct() {}
// Globaler Einstiegspunkt
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->data[$key] = $value;
}
public function get(string $key): mixed
{
return $this->data[$key] ?? null;
}
}
// Verwendung: überall im Code
Registry::getInstance()->set('current_product', $product);
$product = Registry::getInstance()->get('current_product');
Magento 1 war voll davon. Magento 2 hat das offiziell abgeschafft – aber durch die Hintertür zurückgebracht, diesmal im DI-Container.
2. Shared vs. Non-shared Objects: Magentos Singleton-Mechanismus
In Magento 2 gibt es keine expliziten Singletons mehr. Stattdessen gibt es Shared Objects: Der DI-Container (ObjectManager) erstellt von jeder Klasse standardmäßig nur eine Instanz pro Request und gibt diese bei jedem weiteren Aufruf wieder zurück.
<!-- etc/di.xml: Shared ist DEFAULT (muss nicht angegeben werden) -->
<config>
<!-- Diese zwei Konfigurationen sind identisch: -->
<!-- Explizit als shared (Singleton-Verhalten): -->
<type name="Mironsoft\Blog\Model\Config" shared="true"/>
<!-- Implizit als shared (Default-Verhalten): -->
<type name="Mironsoft\Blog\Model\Config"/>
<!-- Explizit als NON-shared (neue Instanz bei jedem resolve): -->
<type name="Mironsoft\Blog\Model\Post" shared="false"/>
</config>
<?php
// Shared Object: gleiche Instanz, egal wie oft injiziert
class ServiceA
{
public function __construct(
private readonly Config $config // Instanz #1
) {}
}
class ServiceB
{
public function __construct(
private readonly Config $config // DIESELBE Instanz wie oben
) {}
}
// Wenn ServiceA und ServiceB über DI instanziiert werden,
// bekommen beide exakt dieselbe Config-Instanz.
// Das ist Singleton-Verhalten — nur versteckt im Container.
Regel: Services (Config, Logger, Repositories, ViewModels) sollten shared sein – sie haben keinen Request-spezifischen Zustand. Models (Post, Product, Order) sollten nicht shared sein – sie repräsentieren spezifische Datensätze.
3. ObjectManager und Singletons
Der ObjectManager verwaltet intern zwei Pools: einen für Shared Objects (Singletons) und einen Factory-Mechanismus für Non-shared Objects. Direkte ObjectManager::get()-Aufrufe sind deshalb in eigentlichem Anwendungscode verboten:
<?php
// FALSCH: Direkter ObjectManager-Aufruf (Service Locator Anti-Pattern)
namespace Mironsoft\Blog\Block;
class Post extends \Magento\Framework\View\Element\Template
{
public function getConfig(): Config
{
// ObjectManager::get() = Singleton-Pool direkt ansprechen
// Das ist verboten in eigenem Code!
return \Magento\Framework\App\ObjectManager::getInstance()->get(Config::class);
// Probleme:
// 1. Versteckte Abhängigkeit — kein PHPDoc, kein Constructor
// 2. Nicht testbar — Mock kann nicht injiziert werden
// 3. Globaler Zugriff = Singleton-Anti-Pattern
}
}
// RICHTIG: Constructor Injection
class Post extends \Magento\Framework\View\Element\Template
{
public function __construct(
\Magento\Framework\View\Element\Template\Context $context,
private readonly Config $config, // Explizite Abhängigkeit, testbar
array $data = []
) {
parent::__construct($context, $data);
}
public function getConfig(): Config
{
return $this->config; // Config-Instanz ist shared — kein Problem
}
}
Erlaubte Ausnahmen für ObjectManager-Direktzugriff: Factories, Proxies, Test-Setup und Installer-Klassen. In regulären Services, Blocks, Controllern oder ViewModels: niemals.
4. Versteckte Singletons: Wo sie wirklich stecken
Singletons in Magento 2 sind nicht explizit markiert. Du erkennst sie daran, dass sie statusbehafteten Zustand halten der sich über mehrere Aufrufe verändert:
Versteckte Singletons in Magento Core:
Magento\Framework\Registry
→ Globaler Key-Value-Store (Legacy!)
→ Jeder schreibt rein, jeder liest — Singleton-Verhalten explizit
Magento\Framework\App\Config\ScopeConfigInterface
→ Konfiguration gecacht nach erstem Lesen
→ Shared Object — eine Instanz für alle
Magento\Customer\Model\Session
→ Session-Daten des aktuellen Kunden
→ Shared Object — gefährlich in CLI und Tests
Magento\Framework\Pricing\PriceCurrencyInterface
→ Währungskonvertierung mit gecachtem Kurs
→ Shared — Cache bleibt über den Request
Magento\Catalog\Model\ResourceModel\Product\Collection
→ NICHT shared (shared="false")!
→ Jede Abfrage braucht eine neue Collection-Instanz
<?php
// Das Registry-Problem: Singletons mit globalem Schreibzugriff
// Magento\Framework\Registry ist ein expliziter Singleton:
class ProductView
{
public function __construct(
private readonly \Magento\Framework\Registry $registry
) {}
public function execute(): void
{
// Schreibt in den globalen State
$this->registry->register('current_product', $product);
}
}
class ProductPrice
{
public function __construct(
private readonly \Magento\Framework\Registry $registry
) {}
public function getPrice(): float
{
// Liest aus dem globalen State — implizite Kopplung!
$product = $this->registry->registry('current_product');
// Was wenn ProductView noch nicht gelaufen ist?
// Was wenn ein anderer Code 'current_product' überschreibt?
return $product?->getPrice() ?? 0.0;
}
}
// Registry ist deprecated seit Magento 2.3 — ersetze es mit ViewModel!
5. Warum Singletons Probleme verursachen
Shared Objects mit veränderlichem Zustand sind die gefährlichste Form des Singleton-Patterns. Drei konkrete Probleme:
<?php
// Problem 1: Zustandskorruption zwischen Aufrufen
class PriceCalculator
{
private float $discount = 0.0; // MUTABLE STATE in einem Shared Object!
public function setDiscount(float $discount): void
{
$this->discount = $discount;
}
public function calculate(float $price): float
{
return $price * (1 - $this->discount);
}
}
// Wenn PriceCalculator shared ist:
// Aufruf 1: setDiscount(0.1) → calculate() → 90.0 ✓
// Aufruf 2: calculate() → 90.0 statt 100.0 ✗
// → Discount bleibt aus Aufruf 1 gesetzt!
// Lösung: Entweder Non-shared oder zustandslos (immutable) machen:
class PriceCalculator
{
// Kein interner Zustand — alle Parameter explizit:
public function calculate(float $price, float $discount = 0.0): float
{
return $price * (1 - $discount);
}
}
<?php
// Problem 2: Session-Singleton in CLI-Umgebung
class CustomerPriceProvider
{
public function __construct(
private readonly \Magento\Customer\Model\Session $customerSession
) {}
public function getCustomerGroupId(): int
{
// Im Browser: funktioniert, Session hat Kundendaten
// In CLI (cron, import): Session ist leer/fehlerhaft!
// Der Singleton-Mechanismus gibt die GLEICHE Session-Instanz zurück
// — aber in CLI gibt es keine HTTP-Session!
return (int) $this->customerSession->getCustomerGroupId();
}
}
// Lösung: CustomerGroupId direkt aus Repository holen,
// oder Proxy nutzen: \Magento\Customer\Model\Session\Proxy
<?php
// Problem 3: Collection-Singleton (wenn shared="true" gesetzt würde)
// Collections sammeln alle geladenen Items — bei shared=true
// würden alle Module dieselbe Collection teilen!
// FALSCH (hypothetisch):
// <type name="Magento\Catalog\Model\ResourceModel\Product\Collection" shared="true"/>
class ProductListA
{
public function __construct(
private readonly \Magento\Catalog\Model\ResourceModel\Product\Collection $collection
) {}
public function getProducts(): array
{
$this->collection->addFieldToFilter('status', 1); // filtert aktive Produkte
return $this->collection->getItems();
}
}
class ProductListB
{
public function __construct(
private readonly \Magento\Catalog\Model\ResourceModel\Product\Collection $collection
) {}
public function getDisabledProducts(): array
{
// GLEICHE Collection-Instanz wie oben! Status-Filter von A ist noch aktiv.
$this->collection->addFieldToFilter('status', 0);
// Ergebnis: falsche Produkte, weil beide Filter aktiv sind
return $this->collection->getItems();
}
}
// Deshalb ist Collection explizit shared="false"!
6. Singleton vs. Testbarkeit: Konkrete Probleme
Singletons sind der Hauptgrund dafür, dass Code schwer testbar ist. Drei Muster, die in Tests scheitern:
<?php
// Test-Problem: ObjectManager::getInstance() lässt sich nicht mocken
class LegacyService
{
public function getProductName(int $id): string
{
// Direkte ObjectManager-Abhängigkeit — nicht injectable!
$product = \Magento\Framework\App\ObjectManager::getInstance()
->create(\Magento\Catalog\Model\Product::class);
$product->load($id);
return $product->getName();
}
}
// Im Test: Unmöglich ohne Magento-Bootstrap!
// PHPUnit kann keine Instanz für ObjectManager::getInstance() injizieren.
// Refaktoriertes Version — testbar:
class ModernService
{
public function __construct(
private readonly \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
) {}
public function getProductName(int $id): string
{
return $this->productRepository->getById($id)->getName();
}
}
// Im Test:
class ModernServiceTest extends \PHPUnit\Framework\TestCase
{
public function testGetProductName(): void
{
$product = $this->createMock(\Magento\Catalog\Api\Data\ProductInterface::class);
$product->method('getName')->willReturn('Test Product');
$repository = $this->createMock(\Magento\Catalog\Api\ProductRepositoryInterface::class);
$repository->method('getById')->with(42)->willReturn($product);
$service = new ModernService($repository);
$this->assertSame('Test Product', $service->getProductName(42));
}
}
7. Non-shared Objects: Wann neue Instanzen nötig sind
Manche Klassen müssen immer als neue Instanz erstellt werden. Dazu gehören alle Klassen, die Request-spezifischen oder Datensatz-spezifischen Zustand halten:
<!-- etc/di.xml: Non-shared konfigurieren -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Models: repräsentieren spezifische Datensätze — NIE shared -->
<type name="Mironsoft\Blog\Model\Post" shared="false"/>
<type name="Mironsoft\Blog\Model\Comment" shared="false"/>
<!-- Data Objects: reine Datentransporter — NIE shared -->
<type name="Mironsoft\Blog\Model\Data\PostData" shared="false"/>
<!-- SearchResults: Ergebnis einer spezifischen Suche — NIE shared -->
<type name="Mironsoft\Blog\Model\SearchResults" shared="false"/>
<!-- Services: zustandslos, gecacht in Ordnung — shared (Default) -->
<!-- type name="Mironsoft\Blog\Model\PostRepository" shared="true"/ -->
<!-- Nicht nötig, ist Default -->
</config>
Faustregel: Alles was Daten eines spezifischen Datensatzes hält (Model, Data Object, Collection-Item) → Non-shared. Alles was Services oder Logik bereitstellt ohne eigenen zustandsbehafteten Datenbezug → Shared.
8. Factory als Lösung: Neue Instanzen on demand
Wenn du in einem Shared-Service eine neue Non-shared-Instanz brauchst, nutzt du eine Factory. Magento generiert Factories automatisch für jede Klasse, die mit dem Suffix Factory referenziert wird:
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\Model;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Model\PostFactory;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;
/**
* Repository creates new Post instances via Factory — never via new or ObjectManager.
*/
class PostRepository
{
public function __construct(
private readonly PostFactory $postFactory, // Automatisch generiert!
private readonly PostResource $postResource
) {}
/**
* Create a fresh Post instance for each getById() call.
* If PostRepository were shared (it is), but Post is non-shared,
* the factory ensures a new Post object every time.
*/
public function getById(int $id): PostInterface
{
// Factory::create() = new Post() via DI-Container
// Non-shared: jeder create()-Aufruf gibt neue Instanz
$post = $this->postFactory->create();
$this->postResource->load($post, $id);
if (!$post->getId()) {
throw new \Magento\Framework\Exception\NoSuchEntityException(
__('Post with id "%1" does not exist.', $id)
);
}
return $post;
}
}
Automatische Factory-Generierung: Du musst PostFactory nicht selbst schreiben. Sobald du die Klasse als Dependency mit dem Suffix Factory angibst, generiert bin/magento setup:di:compile die Klasse automatisch in generated/code/. Die generierte Factory ruft intern ObjectManager::create(Post::class) auf – non-shared.
9. Legacy-Singletons refaktorieren
Typische Singleton-Anti-Patterns in Magento-Code und deren moderne Alternativen:
<?php
// ANTI-PATTERN 1: Registry für Datenweitergabe
// Alt (deprecated):
class ProductController
{
public function execute(): ResultInterface
{
$product = $this->productRepository->getById(42);
$this->registry->register('current_product', $product); // Global!
return $this->pageFactory->create();
}
}
class ProductBlock extends Template
{
public function getProduct(): ?ProductInterface
{
return $this->registry->registry('current_product'); // Globaler Zugriff!
}
}
// Neu (ViewModel-Pattern):
class ProductViewModel implements ArgumentInterface
{
public function __construct(
private readonly RequestInterface $request,
private readonly ProductRepositoryInterface $productRepository
) {}
public function getProduct(): ?ProductInterface
{
try {
return $this->productRepository->getById(
(int) $this->request->getParam('id')
);
} catch (NoSuchEntityException) {
return null;
}
}
}
// Injiziert via Layout-XML — kein globaler State!
<?php
// ANTI-PATTERN 2: Statischer Zugriff auf Singleton
// Alt:
class LegacyHelper
{
public static function getConfig(string $path): ?string
{
return \Magento\Framework\App\ObjectManager::getInstance()
->get(\Magento\Framework\App\Config\ScopeConfigInterface::class)
->getValue($path);
}
}
// Neu (Injectable Service):
class ConfigProvider
{
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {}
public function getValue(string $path, string $scope = ScopeInterface::SCOPE_STORE): ?string
{
return $this->scopeConfig->getValue($path, $scope);
}
}
// Inject ConfigProvider via Constructor — testbar, explizit.
Mironsoft
Magento 2 Code-Qualität & Refactoring
Anti-Patterns aus deinem Magento-Code entfernen?
Wir analysieren bestehenden Magento-Code auf Singleton-Anti-Patterns, ObjectManager-Direktzugriffe und Registry-Abhängigkeiten – und refaktorieren auf moderne, testbare Patterns.
10. Zusammenfassung
Das Singleton Pattern lebt in Magento 2 weiter – als Shared Objects im DI-Container. Das ist meist sinnvoll für zustandslose Services, aber gefährlich für zustandsbehaftete Klassen wie Models, Collections und Session-Objekte. Der Schlüssel: Constructor Injection statt ObjectManager, Factories für Non-shared Objects, und keine veränderlichen State-Properties in Shared Services.
Singleton Pattern in Magento – Überblick
Shared Objects (Default)
Services, Repositories, Config, Logger → shared ist richtig. Einmal erzeugt, für den ganzen Request wiederverwendet. Kein explizites shared="true" nötig.
Non-shared Objects
Models, Collections, Data Objects → shared="false" in di.xml. Immer über Factory erzeugen, nie direkt per new oder ObjectManager::create().
Anti-Patterns vermeiden
ObjectManager::getInstance() → verboten. Registry für Datenweitergabe → durch ViewModel ersetzen. Statische Methoden → durch Injectable Services ersetzen.
Testbarkeit
Shared Services mit Constructor Injection sind vollständig testbar: Abhängigkeiten via createMock() ersetzen. Keine ObjectManager-Aufrufe = kein Magento-Bootstrap nötig.
11. FAQ: Singleton Pattern in Magento 2
1 Ist jede Magento-Klasse automatisch ein Singleton?
shared=true). Ausnahmen: Klassen mit shared="false" (Models, Collections) und alles über Factory::create() erzeugte.2 Wann sollte ich shared='false' in di.xml setzen?
3 Darf ich ObjectManager::getInstance() verwenden?
4 Factory::create() vs. ObjectManager::create()?
5 Wie ersetze ich die alte Magento Registry?
6 Wie teste ich Klassen mit Shared-Service-Abhängigkeiten?
createMock() für jede Abhängigkeit, Rückgabewerte konfigurieren, Klasse mit Mocks instanziieren. Kein Bootstrap, kein ObjectManager nötig.7 Was passiert wenn ich Session in CLI verwende?
Session\Proxy injizieren (Lazy Loading).8 Wie erkenne ich ob eine Klasse Shared oder Non-shared ist?
grep -r 'shared="false"'. AbstractModel-Erben meist Non-shared. Collections immer Non-shared. Services/Repositories immer Shared. Im Zweifel: Factory verwenden.9 Kann ich Singletons für In-Request-Caching nutzen?
10 Wie funktioniert das Proxy-Pattern als Alternative?
Session::class injiziere Session\Proxy::class — Magento generiert die Proxy-Klasse automatisch.