Contents
- 1. The boundary: what's allowed in constructors?
- 2. Injectable objects: services and managers
- 3. Non-injectable objects: models and DataObjects
- 4. Why models must not be injected
- 5. Factory as the solution: auto-generated factories
- 6. Custom factories and factory methods
- 7. Shared vs. non-shared in di.xml
- 8. Proxy: lazy loading for expensive injectable objects
- 9. PHPStan: finding non-injectable violations
- 10. Conclusion: the golden rule of DI
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
Audit your DI architecture
Find non-injectable violations, implement the Factory pattern correctly, configure shared/non-shared behavior.