Injectable vs. Non-Injectable Objects in Magento 2: The Underestimated Boundary | Mironsoft
AI generated
DI
FACTORY
Deep Dive · Magento 2 Dependency Injection

Injectable vs. Non-Injectable:
The underestimated boundary in Magento

Why Models and DataObjects must never be injected into constructors, how Factories solve this, and exactly what the DI container classifies as "injectable".

12 min read
Magento 2.4.8 · PHP 8.4
Magento DI Concepts

One of the most common causes of bugs in Magento modules: a Model gets injected directly into a constructor. This is subtly wrong and causes unexpected side effects, shared state between requests, memory leaks in CLI commands, and hard-to-reproduce bugs. Magento's DI container fundamentally distinguishes between Injectable and Non-Injectable objects.

1. The boundary: what's allowed in constructors?

Magento's DI container manages two categories of objects with fundamentally different lifecycles:


INJECTABLE (singleton scope, safe in the constructor):
──────────────────────────────────────────────────
  Services      Helper     Manager     Repository
  Factory       Provider   Processor   Builder
  Observer      Plugin     Validator   Formatter
  → Stateless or deliberately stateful (Session, Config)
  → One instance per DI container context
  → Safely shared between all users

NON-INJECTABLE (instance scope, ONLY via Factory):
─────────────────────────────────────────────────
  Model         DataObject  Collection  Request
  Response      Cookie      UrlInterface (sometimes)
  → Carries request-specific state
  → Must be freshly created for every use
  → Never shared between different calls
    

2. Injectable objects: services and managers

Injectable objects can safely be used in constructors. They are typically stateless, or their state is valid for all users:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Model;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Event\ManagerInterface;
use Magento\Store\Model\StoreManagerInterface;
use Psr\Log\LoggerInterface;

/**
 * All constructor parameters here are Injectable, correct usage.
 */
final class ProductService
{
    public function __construct(
        // ✓ INJECTABLE: Repository (stateless service)
        private readonly ProductRepositoryInterface $productRepository,

        // ✓ INJECTABLE: Config (reads from DB/file, no request state)
        private readonly ScopeConfigInterface $scopeConfig,

        // ✓ INJECTABLE: Event Manager (stateless dispatcher)
        private readonly ManagerInterface $eventManager,

        // ✓ INJECTABLE: Store Manager (reads store config, shared)
        private readonly StoreManagerInterface $storeManager,

        // ✓ INJECTABLE: Logger (writes logs, stateless per call)
        private readonly LoggerInterface $logger,

        // ✓ INJECTABLE: Factory (creates new objects on demand)
        private readonly \Magento\Catalog\Model\ProductFactory $productFactory,
    ) {}
}
    

The DI container treats injectable objects as shared (singleton): the same instance is always returned:


<?php

// Inside the DI container: shared objects are cached
// (simplified from Magento\Framework\ObjectManager\ObjectManager)

class ObjectManager
{
    private array $sharedInstances = [];

    public function get(string $type): object
    {
        if (isset($this->sharedInstances[$type])) {
            return $this->sharedInstances[$type]; // ← return the singleton
        }

        $instance = $this->create($type);
        $this->sharedInstances[$type] = $instance;
        return $instance;
    }
}
    

3. Non-injectable objects: models and DataObjects

Non-injectable objects carry request-specific state and must never be injected directly:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Model;

use Magento\Catalog\Model\Product; // ← NON-INJECTABLE

/**
 * WRONG: injecting Product directly
 */
final class WrongProductService
{
    public function __construct(
        // ✗ WRONG: Product is Non-Injectable!
        // The DI container returns the SAME Product instance for every call
        // → shared state between all users of the service
        private readonly Product $product,
    ) {}

    public function process(int $productId): void
    {
        // This load() modifies the SHARED $this->product
        // If service A calls load(1) and service B then calls load(2),
        // both might see the same state, depending on execution order
        $this->product->load($productId);
        // ...
    }
}
    

You can recognize non-injectable classes by the fact that they:

  • extend Magento\Framework\Model\AbstractModel (all Models)
  • extend Magento\Framework\DataObject (DataObjects)
  • extend Magento\Framework\Data\Collection (Collections)
  • carry request-specific data (getRequest() instances)
  • are explicitly marked "newable" in the Magento glossary

4. Why models must not be injected

The problem isn't obvious, but it's destructive. Demonstration example:


<?php

// SCENARIO: two controller instances share a service
// that injected a Model directly

// Service injects Product directly (wrong!)
class ProductDisplayService
{
    public function __construct(private readonly Product $product) {}

    public function getProductName(int $id): string
    {
        $this->product->load($id); // Modifies the SHARED Product
        return $this->product->getName();
    }
}

// Controller A:
$service->getProductName(1); // Loads product ID=1, $this->product has name="iPhone"

// Controller B (same service, same instance!):
// In Magento: services are shared → SAME service, SAME Product instance
$service->getProductName(42); // Loads product ID=42, $this->product has name="Galaxy"

// Controller A afterwards (theoretically):
echo $this->product->getName(); // "Galaxy" ← BUG! Wrong product name!
    

In practice, this problem is especially prevalent in:

  • CLI commands running in a loop (batch imports)
  • Queue consumers processing many messages
  • Magento unit tests running multiple test cases in one class
  • GraphQL resolvers handling multiple parallel requests

5. Factory as the solution: auto-generated factories

The solution is simple: inject the corresponding Factory instead of the Model. Magento generates factories automatically:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Model;

use Magento\Catalog\Model\ProductFactory; // ← Automatically generated

/**
 * CORRECT: injecting the Factory instead of the Model.
 * ProductFactory is automatically generated in generated/code/
 */
final class CorrectProductService
{
    public function __construct(
        // ✓ CORRECT: Factory is injectable (stateless)
        private readonly ProductFactory $productFactory,
    ) {}

    public function process(int $productId): string
    {
        // Factory::create() produces a NEW Product instance on every call
        $product = $this->productFactory->create();
        $product->load($productId);

        // $product is local, no shared state problems
        return $product->getName();
    }

    public function createNewProduct(array $data): \Magento\Catalog\Model\Product
    {
        // create() can also receive data as a parameter
        return $this->productFactory->create(['data' => $data]);
    }
}
    

What Magento generates automatically, the factory class:


<?php

// generated/code/Magento/Catalog/Model/ProductFactory.php
// (automatically generated, never edit manually!)

namespace Magento\Catalog\Model;

use Magento\Framework\ObjectManagerInterface;

class ProductFactory
{
    public function __construct(
        private readonly ObjectManagerInterface $objectManager,
        private readonly string $instanceName = Product::class,
    ) {}

    /**
     * Creates a new Product instance, never returns shared instance.
     *
     * @param array $data Constructor data for the model
     */
    public function create(array $data = []): Product
    {
        // ObjectManager::create(), NOT get(), never returns shared instance
        return $this->objectManager->create($this->instanceName, $data);
    }
}
    

The crucial difference: ObjectManager::create() vs. ObjectManager::get():


<?php

// get(): always returns the SAME (shared/singleton) instance
$service = $objectManager->get(ProductService::class);
$same    = $objectManager->get(ProductService::class);
// $service === $same → true

// create(): always creates a NEW instance
$product1 = $objectManager->create(Product::class);
$product2 = $objectManager->create(Product::class);
// $product1 === $product2 → false (different instances!)
    

6. Custom factories and factory methods

For more complex creation logic, you can write your own factory classes:


<?php

declare(strict_types=1);

namespace Mironsoft\Blog\Model;

use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Api\Data\PostInterfaceFactory;

/**
 * Custom factory with domain-specific creation logic.
 * Goes beyond the auto-generated Factory::create().
 */
final class PostFactory
{
    public function __construct(
        private readonly PostInterfaceFactory $postFactory,
        private readonly \Magento\Store\Model\StoreManagerInterface $storeManager,
    ) {}

    /**
     * Creates a published post with store-specific defaults.
     */
    public function createPublished(string $title, string $content): PostInterface
    {
        $post = $this->postFactory->create();
        $post->setTitle($title);
        $post->setContent($content);
        $post->setIsPublished(true);
        $post->setStoreId((int) $this->storeManager->getStore()->getId());
        $post->setCreatedAt((new \DateTime())->format('Y-m-d H:i:s'));
        return $post;
    }

    /**
     * Creates a draft post.
     */
    public function createDraft(string $title): PostInterface
    {
        $post = $this->postFactory->create();
        $post->setTitle($title);
        $post->setIsPublished(false);
        return $post;
    }

    /**
     * Creates a post from imported data array.
     *
     * @param array{title: string, content: string, is_published: bool} $data
     */
    public function createFromImport(array $data): PostInterface
    {
        $post = $this->postFactory->create();
        $post->setTitle($data['title']);
        $post->setContent($data['content'] ?? '');
        $post->setIsPublished($data['is_published'] ?? false);
        return $post;
    }
}
    

7. Shared vs. non-shared in di.xml

You can control the DI behavior explicitly via shared in di.xml:


<!-- app/code/Mironsoft/Catalog/etc/di.xml -->
<config>
    <!-- shared="true" (default): singleton, get() always returns the same instance -->
    <type name="Mironsoft\Catalog\Model\ProductService" shared="true"/>

    <!-- shared="false": every get() creates a new instance (like create()) -->
    <!-- Needed when a service itself carries state -->
    <type name="Mironsoft\Catalog\Model\StatefulProcessor" shared="false"/>

    <!-- For Collections: ALWAYS non-shared -->
    <type name="Magento\Catalog\Model\ResourceModel\Product\Collection" shared="false"/>
</config>
    

<?php

// When shared="false" makes sense for a service:

/**
 * This service accumulates state during its lifecycle.
 * shared="false" ensures each injector gets a fresh instance.
 */
class ImportProgressTracker
{
    private int $processedCount = 0;
    private array $errors = [];

    public function increment(): void { $this->processedCount++; }
    public function addError(string $msg): void { $this->errors[] = $msg; }
    public function getReport(): array {
        return ['count' => $this->processedCount, 'errors' => $this->errors];
    }
}

// With shared="false" in di.xml:
// Every service that injects ImportProgressTracker gets its own instance
// → No state leak between different import jobs
    

8. Proxy: lazy loading for expensive injectable objects

If an injectable service is expensive to initialize but isn't always needed, you can use proxies:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Model;

/**
 * StoreManager is expensive to initialize.
 * Using a Proxy avoids initialization until first use.
 */
final class CategoryService
{
    public function __construct(
        // Magento automatically generates StoreManager\Proxy
        // when you specify it as a type in di.xml or directly
        private readonly \Magento\Store\Model\StoreManagerInterface $storeManager,
    ) {}
}
    

<!-- di.xml: specifying a Proxy for an expensive service -->
<config>
    <type name="Mironsoft\Catalog\Model\CategoryService">
        <arguments>
            <argument name="storeManager" xsi:type="object">
                Magento\Store\Model\StoreManager\Proxy
            </argument>
        </arguments>
    </type>
</config>
    

Magento generates the proxy class automatically in generated/code/. The proxy implements the same interface, but only initializes the real object on the first method call.

9. PHPStan: finding non-injectable violations

PHPStan with the Magento-specific PHPStan extension can detect non-injectable violations:


<?php

// Error classes PHPStan detects with magento/magento-coding-standard:

// 1. Model directly in the constructor
class WrongService
{
    public function __construct(
        private readonly \Magento\Catalog\Model\Product $product // ← PHPStan Error
    ) {}
}
// Error: Class Magento\Catalog\Model\Product is non-injectable. Use factory instead.

// 2. Collection directly in the constructor
class WrongCollectionService
{
    public function __construct(
        private readonly \Magento\Catalog\Model\ResourceModel\Product\Collection $collection // ← PHPStan Error
    ) {}
}
// Error: Class ...Collection is non-injectable. Use CollectionFactory instead.
    

Grep commands for manually finding non-injectable violations:


# Find direct Model injections in the constructor (simplified grep)
grep -rn "Model\\\\" src/app/code/Mironsoft/*/Model/*.php \
    | grep "private readonly\|protected" \
    | grep -v "Factory\|Interface\|Repository"

# PHPStan analysis with the Magento extension
bin/analyse --level=8 app/code/Mironsoft/
# or
./vendor/bin/phpstan analyse \
    --configuration=dev/tests/static/phpstan.xml \
    app/code/Mironsoft/

# Find all Factory usages (for review)
grep -rn "Factory" src/app/code/Mironsoft/ \
    | grep "public function __construct" -A 10 \
    | grep "Factory"
    

10. Conclusion: the golden rule of DI

The rule is easy to remember:

Golden Rule

Only inject what is stateless or deliberately shared.

Models, Collections and DataObjects: always create via Factory.

✓ Inject directly (Injectable)

  • Repositories (ProductRepositoryInterface)
  • Factories (*Factory)
  • Helpers, Formatters, Validators
  • Config (ScopeConfigInterface)
  • Logger, EventManager
  • StoreManager, UrlInterface

✗ Never inject directly (Non-Injectable)

  • Models (extends AbstractModel)
  • DataObjects (extends DataObject)
  • Collections (extends AbstractCollection)
  • Request/Response objects
  • Objects with request-specific state

Summary

Injectable
Services, Repositories, Factories, Config: stateless or deliberately shared, safe in the constructor
Non-Injectable
Models, DataObjects, Collections: request-specific state, always create via Factory inside methods
Factory
create() is not get(): Factory::create() always produces a new instance, get() returns the singleton
shared in di.xml
shared="false" for services with their own state; Collections and Models are always non-shared

Audit your DI architecture

Find non-injectable violations, implement the Factory pattern correctly, configure shared/non-shared behavior.

????
DI audit
Find every non-injectable violation across the codebase
????
Factory refactoring
Replace direct Model injections with Factories
????
PHPStan Level 8
Set up static analysis for Injectable errors

Frequently asked questions about Injectable vs. Non-Injectable in Magento

What are Injectable and Non-Injectable objects? +
Injectable are services, repositories, factories: stateless or deliberately shared, safe in the constructor. Non-Injectable are models, DataObjects, collections: request-specific state, only create via Factory inside methods.
Why can't I inject a Magento Model directly? +
The DI container manages injectable objects as singletons, so the same instance is shared. A model with request-specific state would be shared by every user of the service, leading to data leaks.
How do I create a Model correctly without direct injection? +
Inject ProductFactory instead of Product into the constructor. Then call $this->productFactory->create() inside the method. create() always creates a new instance (never shared).
What does Magento generate automatically for factories? +
Magento automatically generates factory classes in generated/code/ when you request {ClassName}Factory. The generated class has create(), which calls ObjectManager::create() (not get()), always a new instance.
What is the difference between ObjectManager::get() and create()? +
get() always returns the same (shared) instance. create() always creates a new instance. Factory::create() internally calls create(), not get().
What does shared='false' mean in di.xml? +
Instead of always returning the same instance, a new instance is created on every call. Useful for services that accumulate state. Collections should always be shared='false'.
What is a Magento Proxy? +
An automatically generated wrapper class that lazily initializes an expensive injectable object, only on the first method call. Configurable in di.xml as an argument with the Proxy class name.
How does PHPStan detect Non-Injectable violations? +
With magento/magento-coding-standard and the PHPStan extension, PHPStan detects direct injections of models in the constructor. Error message: 'Class X is non-injectable. Use factory instead.'
Are all service classes automatically Injectable? +
Not all of them: services that accumulate their own state should be shared='false'. Purely stateless services default to shared='true' and are safely injectable.
Can I write my own Factory class? +
Yes. Useful when create() isn't enough, for example for domain-specific objects with predefined defaults. Inject the auto-generated Factory and add factory methods like createPublished().