Table of Contents
- The GoF Singleton pattern: concept and PHP implementation
- Why the classic Singleton is problematic in modern PHP
- Shared instances: Magento's elegant Singleton alternative
- shared=true vs. shared=false in di.xml, when to use what
- The Registry pattern: a global data bucket and its history
- Why Magento\\Framework\\Registry is deprecated
- Registry alternatives: context objects, session, and ViewModel injection
- Migration strategy: systematically replacing Registry usage
- Summary
- FAQ
The GoF Singleton pattern: concept and PHP implementation
The Singleton pattern is one of the best-known design patterns from the 1994 book "Design Patterns: Elements of Reusable Object-Oriented Software" by the Gang of Four. It solves a specific problem: it should be guaranteed that exactly one instance of a given class exists, and there should be a global access point to that instance. Typical candidates for Singletons are loggers, configuration objects, connection pools, and registry-like data containers.
The classic PHP implementation of the Singleton pattern uses three core elements. First, a private constructor that prevents external classes from creating a new instance. Second, a static property $instance, which stores the single existing instance. Third, a public static method getInstance(), which creates the instance on the first call and returns the same one on every subsequent call.
In addition, a correct PHP Singleton implementation also declares __clone() and __wakeup() as private or protected, to prevent cloning and deserialization of the instance, both of which would undermine the Singleton guarantee. In PHP 8.0+ you can use readonly and other modern features, but the underlying pattern remains the same.
The GoF Singleton was an elegant pattern at the time it was invented, for languages without true dependency injection. Today, in a world of modern DI containers, the classic Singleton is largely outdated, not because the concept is wrong, but because its implementation brings specific problems that modern frameworks solve more elegantly.
<?php
declare(strict_types=1);
// Classic PHP Singleton, conceptually correct, but do NOT use it in Magento 2
class LegacyConfigRegistry
{
private static ?self $instance = null;
private array $data = [];
// Private constructor: no external instantiation possible
private function __construct() {}
// Cloning forbidden: would create a second instance
private function __clone(): void {}
// Deserialization forbidden: would create a second instance
public function __wakeup(): void
{
throw new \RuntimeException('Singletons cannot be deserialized.');
}
/**
* Get the singleton instance, created on first call.
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function get(string $key): mixed
{
return $this->data[$key] ?? null;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
}
// Usage, anti-pattern in Magento 2:
$config = LegacyConfigRegistry::getInstance();
$config->set('base_url', 'https://example.com');
Why the classic Singleton is problematic in modern PHP
The biggest problem with the classic Singleton pattern is its static nature. getInstance() is a static method that accesses global state. Static calls cannot easily be overridden or mocked in PHP unit tests. This means every class that uses a Singleton has a hidden dependency on global state that cannot be removed in isolated unit tests. Tests turn into integration tests without isolation.
The second problem is test contamination between test cases. Because the Singleton holds a static instance, its state survives from one test to the next within the same PHP process. If Test A changes the Singleton state, Test B sees that changed state. This leads to random test failures that depend on execution order, one of the worst classes of bugs in automated test systems.
The third problem concerns interchangeability. A classic Singleton is the same class for every caller. There is no way to use a different implementation for a particular context, for example a specialized implementation for certain store views or a lightweight implementation for CLI contexts. DI containers solve this elegantly through preferences and virtual types.
In Magento 2, these problems are well known, and the framework core deliberately does not use the classic static Singleton. Instead, the Singleton concept is implemented through the DI container, with all the advantages of the concept (one instance per request, shared state) without its drawbacks (static coupling, no testability). The result is called a shared instance.
Shared instances: Magento's elegant Singleton alternative
Magento 2 implements the Singleton concept through the DI container. When the ObjectManager resolves an injectable class for the first time, it creates the instance and stores it in an internal $sharedInstances array. All subsequent requests for this class, whether through constructor injection or through ObjectManager::get(), receive the same instance back. That is Singleton behavior, without the problematic properties of the classic pattern.
The crucial difference from the classic Singleton: the shared instance is not enforced at the class level, but controlled by the DI container. In unit tests, you simply create a new instance of the class with new; there is no private constructor preventing that. The constructor is public, and all dependencies can be passed as mock objects. The DI container provides the Singleton semantics in the running system, without forcing them into the test context.
Another advantage: shared instances can be swapped out via the preference configuration in di.xml. If a module should use a different implementation of an interface, one entry in di.xml is enough: all classes that get the interface injected automatically receive the new implementation. The classic Singleton offers no comparable mechanism.
In Magento 2.4.8, all services (repositories, loggers, SearchCriteriaBuilder, session objects, EventManager) are shared instances by default. This means: no matter how many classes inject a given service, only one instance exists per request. That saves memory, avoids redundant initializations, and ensures that all parts of the system see the same state of a service, without needing a global Singleton access point.
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\ViewModel;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
/**
* Blog post list ViewModel.
* PostRepositoryInterface is a shared instance: one instance per request,
* injected into every class that depends on it. No Singleton boilerplate needed.
*/
class PostList implements ArgumentInterface
{
public function __construct(
// Shared instance: the DI container ensures only one exists per request
private readonly PostRepositoryInterface $postRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder
) {}
/**
* Get the latest published blog posts.
*
* @return \Mironsoft\Blog\Api\Data\PostInterface[]
*/
public function getLatestPosts(int $limit = 5): array
{
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 'published')
->setPageSize($limit)
->setCurrentPage(1)
->create();
return $this->postRepository->getList($searchCriteria)->getItems();
}
}
shared=true vs. shared=false in di.xml, when to use what
The shared parameter in di.xml controls whether the DI container enables or disables Singleton behavior for a type. The default for all injectable classes is shared="true", meaning these classes are managed as shared instances. You only need to set this value explicitly when you want to deviate from the default.
shared="false" is set for non-injectable objects, that is, classes that hold their own, non-shareable state. In Magento this mainly concerns entities (Product, Quote, Order, Customer, Address), collections, and value objects. These classes must not be treated as shared instances, because they represent database data that differs for every context.
In practice, non-shared classes are not injected directly, but instantiated through generated factory classes. If you inject ProductFactory into a class and call $this->productFactory->create(), the factory internally creates a new Product instance every time. The factory itself is a shared instance, the Product is not. This pattern is the correct Magento implementation of the Factory pattern for non-injectable objects.
A common misunderstanding: virtual types in di.xml can also be configured with shared. If you need several variants of the same service with different constructor arguments (for example two loggers with different channels), you create virtual types. These automatically inherit the shared behavior of the parent class but can override it.
<!-- di.xml: explicitly controlling shared configuration -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- SHARED (default, can be omitted): one instance per request -->
<type name="Mironsoft\Blog\Model\PostRepository" shared="true"/>
<!-- NON-SHARED: every injection gets a new instance, rarely needed since factories are used -->
<type name="Mironsoft\Blog\Model\Post" shared="false"/>
<!-- Virtual type: different logger instances with different channels -->
<virtualType name="MironsoftBlogLogger" type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="name" xsi:type="string">MironsoftBlog</argument>
<argument name="handlers" xsi:type="array">
<item name="system" xsi:type="object">Magento\Framework\Logger\Handler\System</item>
</argument>
</arguments>
</virtualType>
<!-- Inject the virtual type (shared by default) -->
<type name="Mironsoft\Blog\Model\PostService">
<arguments>
<argument name="logger" xsi:type="object">MironsoftBlogLogger</argument>
</arguments>
</type>
</config>
The Registry pattern: a global data bucket and its history
The Registry pattern implements a central, globally accessible data store, a key-value store at the application level. Objects can be registered under a key and retrieved by any other component under that same key. The pattern solves a real problem: how do you pass context data, for example the currently displayed product or the current category, through multiple layers of an application, without threading it as a parameter through every function call?
In Magento 2.0 through 2.2, Magento\Framework\Registry was the standard solution to this problem. The controller initialized the current product and registered it under the key current_product. Blocks, observers, and helpers read it from the registry. The same pattern applied to current_category, current_order, and many other context objects.
The pattern was widely used in Magento 1, where the global data bucket was called Mage::registry() and Mage::register(). During the transition to Magento 2, the global function was replaced by an injectable class, Magento\Framework\Registry, but the fundamental problem of global state remained.
The Registry class offered two core methods: register(string $key, mixed $value) for storing and registry(string $key) for retrieving. The method unregister() allowed removing entries. Since the registry itself was a shared instance, its state was globally visible for the entire request, which didn't solve the problem of global state, it merely hid it behind an injectable class.
Why Magento\Framework\Registry is deprecated
Starting with Magento 2.3.x, Magento\Framework\Registry was officially marked as deprecated. The reasons are numerous and fundamental. The first and most serious problem: whoever calls $this->registry->registry('current_product') declares no dependency on the product, only on the registry. What data the registry contains at a given moment is not apparent from the class. The actual contract of the class, "I need a product", is hidden.
The second problem is the lack of type safety. Registry::registry() returns mixed. IDEs cannot provide type hints, PHPStan cannot detect type errors, and developers have to guess the type of the registry value from context. In PHP 8.4 with strict typing, that is a significant step backward: you leave the safety of the type system exactly where types matter most.
The third problem is the previously mentioned test contamination. Because the registry is a shared instance and its state remains globally in memory, a Registry register() call in one test contaminates all following tests in the same PHP process. In Magento's test suite, the registry state must therefore be manually cleaned up after every test, which is error-prone and significantly slows down test development.
The fourth problem concerns extensibility and maintainability of Magento code. If many modules read the same registry key current_product, there is an implicit coupling between the writer (the controller) and all readers. If the controller does not set the product, or registers it under a different key, all dependent classes break, but without a clear error. The key is a magic string that cannot be checked by type-checking tools.
Registry alternatives: context objects, session, and ViewModel injection
The replacement for Registry usage depends on what kind of data is stored and in which context it is read. The most common and cleanest alternative for request context data is a typed context class as a shared instance. This class is a simple DTO-like class with typed set() and get() methods. Since it is a shared instance, every class that injects it sees the same state, just like with the registry, but type-safe and explicit.
For session data that must persist across multiple requests, Magento's session objects are the right choice: Magento\Checkout\Model\Session, Magento\Customer\Model\Session, or a custom session object. These are declared through constructor injection and are fully type-safe. In the Hyva world, much of this data is loaded via Alpine.js state and API calls, which reduces session dependencies in the PHP layer.
For product pages and category pages, where the current object is loaded in the controller, Magento 2.4.8 often provides the option to load the object through the request context. A context class set in an observer or controller plugin is the cleanest solution. Alternatively, a ViewModel can load the object directly through a repository call, if the product page already contains a request parameter.
In some cases, Registry usage is a sign that data is being loaded too early in the request and carried through the system for too long. One alternative is lazy loading: the ViewModel loads the product itself when it needs it, instead of waiting for externally set state. In Magento 2.4.8 with full page cache, this is often the better strategy: the product data is cached for every page anyway.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model\Context;
use Magento\Catalog\Api\Data\ProductInterface;
/**
* Typed context holder for the current product in the request scope.
* Registered as shared="true" (default) in di.xml.
* Replaces: $registry->registry('current_product')
*/
class CurrentProduct
{
private ?ProductInterface $product = null;
/**
* Set the current product (called from Controller or Observer).
*/
public function set(ProductInterface $product): void
{
$this->product = $product;
}
/**
* Get the current product, null if not set in this request scope.
*/
public function get(): ?ProductInterface
{
return $this->product;
}
/**
* Check whether a current product has been set.
*/
public function has(): bool
{
return $this->product !== null;
}
/**
* Clear the current product context.
*/
public function clear(): void
{
$this->product = null;
}
}
Migration strategy: systematically replacing Registry usage
Migrating Registry usage to clean alternatives should happen systematically. The first step is a complete inventory of all Registry calls in the project. With grep -rn 'registry->' src/app/code and grep -rn "use Magento\\Framework\\Registry" src/app/code you find all occurrences. PHPStan with the appropriate rules can automate this search and run it continuously as part of the CI/CD process.
The second step is categorizing the Registry usages found. Is it product context (current_product, current_category)? Then a typed context class is the right choice. Is it a temporary calculation result passed along within a request? Then a shared-instance class with the appropriate type is the alternative. Is it session data? Then the session object should be used directly.
The third step is the actual refactoring: create a context class per Registry key, replace the Registry injection with the context class injection, and replace all register() calls with set() calls on the context class. The fourth step is writing unit tests for all refactored classes, this time without Registry state problems.
For teams actively developing on Magento 2.4.8, a zero-tolerance policy for new Registry usage is recommended: in code reviews, every use Magento\Framework\Registry import in new classes is rejected immediately. PHPStan rules in the CI/CD pipeline ensure that no new Registry calls slip in unnoticed. Existing Registry usages are gradually replaced as part of other work on the affected modules.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Magento\Catalog\Api\Data\ProductInterface;
use Mironsoft\Catalog\Model\Context\CurrentProduct;
/**
* Sets the current product context on catalog_controller_product_init_after.
* Replaces: $registry->register('current_product', $product)
*/
class SetCurrentProductContext implements ObserverInterface
{
public function __construct(
private readonly CurrentProduct $currentProduct
) {}
/**
* Set the current product in the typed context class (shared instance).
* All other classes that inject CurrentProduct will see the same instance.
*/
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
if ($product instanceof ProductInterface) {
$this->currentProduct->set($product);
}
}
}
// In ViewModel: typed, no magic strings, fully mockable in tests
class ProductDetailViewModel implements \Magento\Framework\View\Element\Block\ArgumentInterface
{
public function __construct(
private readonly CurrentProduct $currentProduct
) {}
public function getProduct(): ?ProductInterface
{
// Type-safe, IDE-supported, no Registry needed
return $this->currentProduct->get();
}
public function getProductName(): string
{
return $this->currentProduct->get()?->getName() ?? '';
}
}
Singleton & Registry: the key takeaways
Shared instance = modern Singleton
Injectable objects are shared="true" by default. One instance per request. Testable, swappable via preferences, no static access.
Registry deprecated since 2.3
Magento\Framework\Registry is forbidden in new modules. Global state, no type safety, test contamination.
Context classes as a replacement
A typed shared-instance class with set() and get(). Observer sets, ViewModel reads. Fully type-safe, mockable, testable in isolation.
shared=false for entities
Product, Quote, Order: shared="false". Instantiated through factory classes, never injected directly.
Summary
The Singleton pattern hasn't disappeared in Magento 2, it has been modernized. Shared instances in the DI container deliver exactly what the classic GoF Singleton promises: a single instance per context, globally accessible. But without the problematic properties: static coupling, lack of testability, and non-interchangeability. Configuring it through shared="true" in di.xml makes the Singleton behavior explicitly configurable and swappable via preference.
The Registry class is a design mistake from early Magento 2 development, carried over from the Magento 1 era. It is deprecated because it introduces global, untyped state into a system built on explicit dependencies and type safety. In Magento 2.4.8, there is a better alternative for every Registry use case: typed context classes, session objects, or direct repository calls in the ViewModel.
The path to clean Magento 2 code runs through three steps: replace the classic Singleton with shared instances, replace Registry usage with typed context classes, and use shared=false in di.xml for entities that are instantiated through factory classes. The result is more testable, more maintainable, and more upgrade-safe code, ready for the next major Magento versions.