Singleton Pattern in Magento 2: Where It Hides and Why You Should Avoid It | Mironsoft
AI generated

Singleton Pattern in Magento 2: Where It Hides and Why You Should Avoid It

· Reading time: approx. 13 minutes · Category: Magento 2 · Design Patterns

shared
Magento 2 · Deep Dive · Design Patterns

Singleton Pattern
Understanding It in Magento 2

Magento's DI container creates shared objects by default, and that is singleton behavior. Where it is useful, where it causes problems, and how to cleanly refactor legacy singletons.

⏱ 13 min Deep Dive Design Patterns PHP 8.4

The Invisible Singleton

The Singleton Pattern is widely considered an anti-pattern in modern PHP, and yet it is deeply embedded in Magento 2. Not as an explicit getInstance() implementation like in Magento 1, but as the default behavior of the DI container: every class resolved through constructor injection is a shared object by default, meaning a singleton for the lifetime of the request.

Often that is exactly what you want. But it is also a source of subtle bugs, testing problems, and unwanted side effects. Anyone who writes Magento code without understanding this ends up with code that behaves differently in production than it does in tests.

This deep dive explains how Magento's singleton mechanism works, where it is useful, where it becomes dangerous, and how to recognize and replace singletons.

1. The GoF Singleton Pattern

The Singleton Pattern from the GoF book (1994) ensures that exactly one instance of a class exists and that this instance is globally accessible. The classic implementation in PHP:


<?php
// Classic singleton (PHP anti-pattern):
class Registry
{
    private static ?self $instance = null;
    private array $data = [];

    // Private constructor: no direct new Registry()
    private function __construct() {}

    // Global entry point
    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;
    }
}

// Usage: anywhere in the code
Registry::getInstance()->set('current_product', $product);
$product = Registry::getInstance()->get('current_product');

Magento 1 was full of this. Magento 2 officially abolished it, but brought it back through the back door, this time inside the DI container.

2. Shared vs. Non-shared Objects: Magento's Singleton Mechanism

Magento 2 no longer has explicit singletons. Instead it has shared objects: the DI container (ObjectManager) creates only one instance per class per request by default and returns that same instance on every subsequent call.


<!-- etc/di.xml: shared is the DEFAULT (does not need to be specified) -->
<config>
    <!-- These two configurations are identical: -->

    <!-- Explicitly shared (singleton behavior): -->
    <type name="Mironsoft\Blog\Model\Config" shared="true"/>

    <!-- Implicitly shared (default behavior): -->
    <type name="Mironsoft\Blog\Model\Config"/>

    <!-- Explicitly NON-shared (new instance on every resolve): -->
    <type name="Mironsoft\Blog\Model\Post" shared="false"/>
</config>

<?php
// Shared object: same instance no matter how often it is injected
class ServiceA
{
    public function __construct(
        private readonly Config $config // Instance #1
    ) {}
}

class ServiceB
{
    public function __construct(
        private readonly Config $config // The SAME instance as above
    ) {}
}

// When ServiceA and ServiceB are instantiated via DI,
// both receive exactly the same Config instance.
// That is singleton behavior, just hidden inside the container.

Rule of thumb: services (Config, Logger, repositories, view models) should be shared, since they hold no request-specific state. Models (Post, Product, Order) should not be shared, because they represent specific data records.

3. ObjectManager and Singletons

The ObjectManager internally manages two pools: one for shared objects (singletons) and a factory mechanism for non-shared objects. Direct ObjectManager::get() calls are therefore forbidden in regular application code:


<?php
// WRONG: direct ObjectManager call (Service Locator anti-pattern)
namespace Mironsoft\Blog\Block;

class Post extends \Magento\Framework\View\Element\Template
{
    public function getConfig(): Config
    {
        // ObjectManager::get() = talking to the singleton pool directly
        // This is forbidden in your own code!
        return \Magento\Framework\App\ObjectManager::getInstance()->get(Config::class);
        // Problems:
        // 1. Hidden dependency, no PHPDoc, no constructor
        // 2. Not testable, a mock cannot be injected
        // 3. Global access = singleton anti-pattern
    }
}

// CORRECT: constructor injection
class Post extends \Magento\Framework\View\Element\Template
{
    public function __construct(
        \Magento\Framework\View\Element\Template\Context $context,
        private readonly Config $config, // Explicit dependency, testable
        array $data = []
    ) {
        parent::__construct($context, $data);
    }

    public function getConfig(): Config
    {
        return $this->config; // Config instance is shared, no problem here
    }
}

Allowed exceptions for direct ObjectManager access: factories, proxies, test setup, and installer classes. In regular services, blocks, controllers, or view models: never.

4. Hidden Singletons: Where They Really Live

Singletons in Magento 2 are not explicitly marked. You recognize them by the fact that they hold mutable state that changes across multiple calls:


Hidden singletons in Magento core:

Magento\Framework\Registry
  → Global key-value store (legacy!)
  → Everyone writes to it, everyone reads from it, explicit singleton behavior

Magento\Framework\App\Config\ScopeConfigInterface
  → Configuration cached after first read
  → Shared object, one instance for everyone

Magento\Customer\Model\Session
  → Session data of the current customer
  → Shared object, dangerous in CLI and tests

Magento\Framework\Pricing\PriceCurrencyInterface
  → Currency conversion with a cached rate
  → Shared, the cache persists across the request

Magento\Catalog\Model\ResourceModel\Product\Collection
  → NOT shared (shared="false")!
  → Every query needs a fresh Collection instance

<?php
// The Registry problem: singletons with global write access
// Magento\Framework\Registry is an explicit singleton:

class ProductView
{
    public function __construct(
        private readonly \Magento\Framework\Registry $registry
    ) {}

    public function execute(): void
    {
        // Writes to global state
        $this->registry->register('current_product', $product);
    }
}

class ProductPrice
{
    public function __construct(
        private readonly \Magento\Framework\Registry $registry
    ) {}

    public function getPrice(): float
    {
        // Reads from global state, implicit coupling!
        $product = $this->registry->registry('current_product');
        // What if ProductView has not run yet?
        // What if some other code overwrites 'current_product'?
        return $product?->getPrice() ?? 0.0;
    }
}
// Registry has been deprecated since Magento 2.3, replace it with a view model!

5. Why Singletons Cause Problems

Shared objects with mutable state are the most dangerous form of the singleton pattern. Three concrete problems:


<?php
// Problem 1: state corruption between calls
class PriceCalculator
{
    private float $discount = 0.0; // MUTABLE STATE in a shared object!

    public function setDiscount(float $discount): void
    {
        $this->discount = $discount;
    }

    public function calculate(float $price): float
    {
        return $price * (1 - $this->discount);
    }
}

// If PriceCalculator is shared:
// Call 1: setDiscount(0.1) → calculate() → 90.0 ✓
// Call 2: calculate() → 90.0 instead of 100.0 ✗
//   → the discount from call 1 is still set!

// Solution: make it either non-shared or stateless (immutable):
class PriceCalculator
{
    // No internal state, all parameters explicit:
    public function calculate(float $price, float $discount = 0.0): float
    {
        return $price * (1 - $discount);
    }
}

<?php
// Problem 2: session singleton in a CLI environment
class CustomerPriceProvider
{
    public function __construct(
        private readonly \Magento\Customer\Model\Session $customerSession
    ) {}

    public function getCustomerGroupId(): int
    {
        // In the browser: works, the session has customer data
        // In CLI (cron, import): the session is empty or broken!
        // The singleton mechanism returns the SAME session instance,
        // but in CLI there is no HTTP session!
        return (int) $this->customerSession->getCustomerGroupId();
    }
}

// Solution: fetch the customer group id directly from the repository,
// or use a proxy: \Magento\Customer\Model\Session\Proxy

<?php
// Problem 3: collection singleton (if shared="true" were set)
// Collections accumulate all loaded items, if shared="true"
// were used, every module would share the same collection!

// WRONG (hypothetical):
// <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); // filters active products
        return $this->collection->getItems();
    }
}

class ProductListB
{
    public function __construct(
        private readonly \Magento\Catalog\Model\ResourceModel\Product\Collection $collection
    ) {}

    public function getDisabledProducts(): array
    {
        // SAME collection instance as above! The status filter from A is still active.
        $this->collection->addFieldToFilter('status', 0);
        // Result: wrong products, because both filters are active at once
        return $this->collection->getItems();
    }
}
// This is exactly why Collection is explicitly shared="false"!

6. Singleton vs. Testability: Concrete Problems

Singletons are the main reason code becomes hard to test. Three patterns that fail in tests:


<?php
// Test problem: ObjectManager::getInstance() cannot be mocked
class LegacyService
{
    public function getProductName(int $id): string
    {
        // Direct ObjectManager dependency, not injectable!
        $product = \Magento\Framework\App\ObjectManager::getInstance()
            ->create(\Magento\Catalog\Model\Product::class);
        $product->load($id);
        return $product->getName();
    }
}

// In a test: impossible without a full Magento bootstrap!
// PHPUnit cannot inject an instance for ObjectManager::getInstance().

// Refactored version, testable:
class ModernService
{
    public function __construct(
        private readonly \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
    ) {}

    public function getProductName(int $id): string
    {
        return $this->productRepository->getById($id)->getName();
    }
}

// In a 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: When New Instances Are Required

Some classes must always be created as a new instance. This includes every class that holds request-specific or record-specific state:


<!-- etc/di.xml: configuring non-shared -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <!-- Models: represent specific data records, NEVER shared -->
    <type name="Mironsoft\Blog\Model\Post" shared="false"/>
    <type name="Mironsoft\Blog\Model\Comment" shared="false"/>

    <!-- Data objects: pure data carriers, NEVER shared -->
    <type name="Mironsoft\Blog\Model\Data\PostData" shared="false"/>

    <!-- SearchResults: result of a specific search, NEVER shared -->
    <type name="Mironsoft\Blog\Model\SearchResults" shared="false"/>

    <!-- Services: stateless, caching is fine, shared (default) -->
    <!-- type name="Mironsoft\Blog\Model\PostRepository" shared="true"/ -->
    <!-- Not necessary, this is the default -->
</config>

Rule of thumb: anything that holds the data of a specific record (model, data object, collection item) → non-shared. Anything that provides services or logic without its own stateful data reference → shared.

8. Factory as a Solution: New Instances on Demand

When you need a new non-shared instance inside a shared service, you use a factory. Magento automatically generates factories for every class referenced with the Factory suffix:


<?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,  // Automatically generated!
        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 the DI container
        // Non-shared: every create() call returns a new instance
        $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;
    }
}

Automatic factory generation: you do not have to write PostFactory yourself. As soon as you declare the class as a dependency with the Factory suffix, bin/magento setup:di:compile generates the class automatically into generated/code/. The generated factory internally calls ObjectManager::create(Post::class), which is non-shared.

9. Refactoring Legacy Singletons

Typical singleton anti-patterns in Magento code and their modern alternatives:


<?php
// ANTI-PATTERN 1: Registry for passing data around
// Old (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'); // Global access!
    }
}

// New (view model 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;
        }
    }
}
// Injected via layout XML, no global state!

<?php
// ANTI-PATTERN 2: static access to a singleton
// Old:
class LegacyHelper
{
    public static function getConfig(string $path): ?string
    {
        return \Magento\Framework\App\ObjectManager::getInstance()
            ->get(\Magento\Framework\App\Config\ScopeConfigInterface::class)
            ->getValue($path);
    }
}

// New (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 the constructor, testable and explicit.

Mironsoft

Magento 2 code quality & refactoring

Want anti-patterns removed from your Magento code?

We analyze existing Magento code for singleton anti-patterns, direct ObjectManager access, and Registry dependencies, then refactor it toward modern, testable patterns.

Code audit
Reviewing existing modules for anti-patterns: direct ObjectManager access, Registry dependencies, static methods, and singleton misuse.
Refactoring
Step by step refactoring: replacing Registry with a view model, ObjectManager with constructor injection, and static classes with injectable ones.
Test setup
PHPUnit tests for refactored classes: mocking strategy, test fixtures, PHPStan integration, and a CI/CD pipeline.

10. Summary

The Singleton Pattern lives on in Magento 2 as shared objects inside the DI container. That is usually reasonable for stateless services, but dangerous for stateful classes like models, collections, and session objects. The key: constructor injection instead of ObjectManager, factories for non-shared objects, and no mutable state properties inside shared services.

Singleton Pattern in Magento, an overview

Shared objects (default)

Services, repositories, Config, Logger → shared is correct. Created once, reused for the entire request. No explicit shared="true" needed.

Non-shared objects

Models, collections, data objects → shared="false" in di.xml. Always create them via a factory, never directly with new or ObjectManager::create().

Avoiding anti-patterns

ObjectManager::getInstance() → forbidden. Registry for passing data around → replace with a view model. Static methods → replace with injectable services.

Testability

Shared services with constructor injection are fully testable: replace dependencies via createMock(). No ObjectManager calls means no Magento bootstrap required.

11. FAQ: Singleton Pattern in Magento 2

1 Is every Magento class automatically a singleton?
By default yes: the DI container creates only one instance per request (shared=true). Exceptions: classes with shared="false" (models, collections) and anything created via Factory::create().
2 When should I set shared='false' in di.xml?
Whenever the class holds the state of a specific data record: models, data objects, SearchResults, collections. Services and repositories are always shared, since they are stateless.
3 Am I allowed to use ObjectManager::getInstance()?
No, forbidden in your own code. Allowed only in: installer classes, generated factories, test bootstrap. In services, blocks, controllers, view models: never. Always use constructor injection.
4 Factory::create() vs. ObjectManager::create()?
Both create new (non-shared) instances. Difference: ObjectManager is a Service Locator (forbidden, not testable). Factory::create() is the correct abstraction: injectable, mockable, automatically generated. Always use a factory.
5 How do I replace the old Magento Registry?
Registry has been deprecated since 2.3. Alternative: view model, fetch data directly from the repository. Or layout XML arguments: configure values directly in the block. Registry was a global key-value store, replace it with explicit dependencies.
6 How do I test classes with shared service dependencies?
Constructor injection means fully testable. In a PHPUnit test: createMock() for every dependency, configure return values, instantiate the class with the mocks. No bootstrap, no ObjectManager required.
7 What happens if I use Session in CLI?
Session is a shared object, and there is no HTTP session in CLI → error or empty data. Solution: load customer data directly from the repository in CLI. If needed: inject Session\Proxy (lazy loading).
8 How do I tell whether a class is shared or non-shared?
Search di.xml: grep -r 'shared="false"'. AbstractModel descendants are usually non-shared. Collections are always non-shared. Services and repositories are always shared. When in doubt: use a factory.
9 Can I use singletons for in-request caching?
Yes, a shared service can cache values in private properties. Since there is only one instance per request, the cache is valid for the whole request. Important: only for temporary request data, not for persistent data.
10 How does the Proxy Pattern work as an alternative?
Proxy means lazy loading: the instance is only created on the first method call. Useful for heavy objects in services. Usage: inject Session\Proxy::class instead of Session::class, Magento generates the proxy class automatically.