Inhalt
- 1. Die Grenze: Was darf in Konstruktoren?
- 2. Injectable Objects: Services und Manager
- 3. Non-Injectable Objects: Models und DataObjects
- 4. Warum Models nicht injiziert werden dürfen
- 5. Factory als Lösung: Auto-generierte Factories
- 6. Eigene Factories und Factory-Methoden
- 7. Shared vs. Non-Shared in di.xml
- 8. Proxy: Lazy Loading für teure Injectable Objects
- 9. PHPStan: Non-Injectable-Fehler finden
- 10. Fazit: Die goldene Regel des DI
Eine der häufigsten Ursachen für Bugs in Magento-Modulen: Ein Model wird direkt in einen Konstruktor injiziert. Das ist subtil falsch und verursacht unerwartete Seiteneffekte — geteilter Zustand zwischen Requests, Memory-Leaks bei CLI-Commands, und schwer reproduzierbare Bugs. Magento's DI-Container unterscheidet fundamental zwischen Injectable und Non-Injectable Objekten.
1. Die Grenze: Was darf in Konstruktoren?
Magento's DI-Container verwaltet zwei Kategorien von Objekten mit fundamental unterschiedlichen Lebenszyklen:
INJECTABLE (Singleton-Scope, sicher im Konstruktor):
──────────────────────────────────────────────────
Services Helper Manager Repository
Factory Provider Processor Builder
Observer Plugin Validator Formatter
→ Stateless oder bewusst stateful (Session, Config)
→ Eine Instanz pro DI-Container-Kontext
→ Sicher geteilt zwischen allen Nutzern
NON-INJECTABLE (Instanz-Scope, NUR via Factory):
─────────────────────────────────────────────────
Model DataObject Collection Request
Response Cookie UrlInterface (manchmal)
→ Zustandsbehaftet mit Request-spezifischen Daten
→ Muss für jede Nutzung frisch erstellt werden
→ Nie geteilt zwischen verschiedenen Aufrufen
2. Injectable Objects: Services und Manager
Injectable Objects können sicher in Konstruktoren verwendet werden. Sie sind typischerweise zustandslos oder ihr Zustand ist für alle Nutzer gültig:
<?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,
) {}
}
Injectable Objects werden vom DI-Container als Shared (Singleton) behandelt — es wird immer dieselbe Instanz zurückgegeben:
<?php
// Intern im DI-Container: Shared objects werden gecacht
// (vereinfacht aus Magento\Framework\ObjectManager\ObjectManager)
class ObjectManager
{
private array $sharedInstances = [];
public function get(string $type): object
{
if (isset($this->sharedInstances[$type])) {
return $this->sharedInstances[$type]; // ← Singleton zurückgeben
}
$instance = $this->create($type);
$this->sharedInstances[$type] = $instance;
return $instance;
}
}
3. Non-Injectable Objects: Models und DataObjects
Non-Injectable Objects haben request-spezifischen Zustand und dürfen nie direkt injiziert werden:
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model;
use Magento\Catalog\Model\Product; // ← NON-INJECTABLE
/**
* FALSCH: Product direkt injizieren
*/
final class WrongProductService
{
public function __construct(
// ✗ FALSCH: Product ist Non-Injectable!
// DI-Container gibt DIESELBE Product-Instanz für alle Aufrufe zurück
// → geteiler Zustand zwischen allen Nutzern des Service
private readonly Product $product,
) {}
public function process(int $productId): void
{
// Dieser load() modifiziert das GETEILTE $this->product
// Wenn Service A load(1) aufruft und Service B danach load(2),
// könnten beide denselben Zustand sehen — je nach Ausführungsreihenfolge
$this->product->load($productId);
// ...
}
}
Non-Injectable-Klassen erkennst du daran, dass sie:
Magento\Framework\Model\AbstractModelerweitern (alle Models)Magento\Framework\DataObjecterweitern (DataObjects)Magento\Framework\Data\Collectionerweitern (Collections)- Request-spezifische Daten tragen (
getRequest()-Instanzen) - Im Magento-Glossar explizit als "newable" markiert sind
4. Warum Models nicht injiziert werden dürfen
Das Problem ist nicht offensichtlich — aber destruktiv. Demonstrationsbeispiel:
<?php
// SZENARIO: Zwei Controller-Instanzen teilen sich einen Service
// der ein Model direkt injiziert hat
// Service injiziert Product direkt (falsch!)
class ProductDisplayService
{
public function __construct(private readonly Product $product) {}
public function getProductName(int $id): string
{
$this->product->load($id); // Modifiziert SHARED Product
return $this->product->getName();
}
}
// Controller A:
$service->getProductName(1); // Lädt Produkt ID=1, $this->product hat Name="iPhone"
// Controller B (selber Service, selbe Instanz!):
// In Magento: Services sind Shared → GLEICHER Service, GLEICHE Product-Instanz
$service->getProductName(42); // Lädt Produkt ID=42, $this->product hat Name="Galaxy"
// Controller A danach (theoretisch):
echo $this->product->getName(); // "Galaxy" ← BUG! Falscher Produktname!
In der Praxis tritt das Problem besonders bei:
- CLI-Commands die in einer Schleife laufen (Batch-Importe)
- Queue-Consumer die viele Messages verarbeiten
- Magento-Unit-Tests die mehrere Test-Cases in einer Klasse ausführen
- GraphQL-Resolver mit mehreren parallelen Anfragen
5. Factory als Lösung: Auto-generierte Factories
Die Lösung ist simpel: Statt des Models die dazugehörige Factory injizieren. Magento generiert Factories automatisch:
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Model;
use Magento\Catalog\Model\ProductFactory; // ← Automatisch generiert
/**
* RICHTIG: Factory statt Model injizieren.
* ProductFactory wird automatisch generiert in generated/code/
*/
final class CorrectProductService
{
public function __construct(
// ✓ RICHTIG: Factory ist injectable (stateless)
private readonly ProductFactory $productFactory,
) {}
public function process(int $productId): string
{
// Factory::create() erzeugt NEUE Product-Instanz bei jedem Aufruf
$product = $this->productFactory->create();
$product->load($productId);
// $product ist lokal — keine geteilten Zustandsprobleme
return $product->getName();
}
public function createNewProduct(array $data): \Magento\Catalog\Model\Product
{
// create() kann auch Daten als Parameter empfangen
return $this->productFactory->create(['data' => $data]);
}
}
Was Magento automatisch generiert — die Factory-Klasse:
<?php
// generated/code/Magento/Catalog/Model/ProductFactory.php
// (automatisch generiert — nie manuell bearbeiten!)
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);
}
}
Der entscheidende Unterschied: ObjectManager::create() vs. ObjectManager::get():
<?php
// get(): Gibt IMMER dieselbe (shared/singleton) Instanz zurück
$service = $objectManager->get(ProductService::class);
$same = $objectManager->get(ProductService::class);
// $service === $same → true
// create(): Erstellt IMMER eine neue Instanz
$product1 = $objectManager->create(Product::class);
$product2 = $objectManager->create(Product::class);
// $product1 === $product2 → false (verschiedene Instanzen!)
6. Eigene Factories und Factory-Methoden
Für komplexere Erstellungslogik kannst du eigene Factory-Klassen schreiben:
<?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
Du kannst das DI-Verhalten explizit über shared in di.xml steuern:
<!-- app/code/Mironsoft/Catalog/etc/di.xml -->
<config>
<!-- shared="true" (Standard): Singleton — get() gibt immer dieselbe Instanz -->
<type name="Mironsoft\Catalog\Model\ProductService" shared="true"/>
<!-- shared="false": Jedes get() erstellt neue Instanz (wie create()) -->
<!-- Nötig wenn ein Service selbst Zustand trägt -->
<type name="Mironsoft\Catalog\Model\StatefulProcessor" shared="false"/>
<!-- Für Collections: IMMER non-shared -->
<type name="Magento\Catalog\Model\ResourceModel\Product\Collection" shared="false"/>
</config>
<?php
// Wann shared="false" für einen Service sinnvoll ist:
/**
* 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];
}
}
// Mit shared="false" im di.xml:
// Jeder Service, der ImportProgressTracker injiziert, bekommt eine eigene Instanz
// → Kein Zustandsleck zwischen verschiedenen Import-Jobs
8. Proxy: Lazy Loading für teure Injectable Objects
Wenn ein Injectable Service teuer zu initialisieren ist, aber nicht immer gebraucht wird, kannst du Proxies verwenden:
<?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 generiert automatisch StoreManager\Proxy
// wenn du es als Typ in di.xml oder direkt angibst
private readonly \Magento\Store\Model\StoreManagerInterface $storeManager,
) {}
}
<!-- di.xml: Proxy für teuren Service angeben -->
<config>
<type name="Mironsoft\Catalog\Model\CategoryService">
<arguments>
<argument name="storeManager" xsi:type="object">
Magento\Store\Model\StoreManager\Proxy
</argument>
</arguments>
</type>
</config>
Magento generiert die Proxy-Klasse automatisch in generated/code/. Der Proxy implementiert dasselbe Interface, initialisiert das echte Objekt aber erst beim ersten Methodenaufruf.
9. PHPStan: Non-Injectable-Fehler finden
PHPStan mit dem Magento-spezifischen PHPStan-Extension kann Non-Injectable-Verletzungen erkennen:
<?php
// Fehler-Klassen die PHPStan mit magento/magento-coding-standard erkennt:
// 1. Model direkt im Konstruktor
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 direkt im Konstruktor
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-Befehle zum manuellen Suchen von Non-Injectable-Verletzungen:
# Finde direkte Model-Injektionen im Konstruktor (vereinfachtes grep)
grep -rn "Model\\\\" src/app/code/Mironsoft/*/Model/*.php \
| grep "private readonly\|protected" \
| grep -v "Factory\|Interface\|Repository"
# PHPStan-Analyse mit Magento-Extension
bin/analyse --level=8 app/code/Mironsoft/
# oder
./vendor/bin/phpstan analyse \
--configuration=dev/tests/static/phpstan.xml \
app/code/Mironsoft/
# Finde alle Factory-Nutzungen (zur Überprüfung)
grep -rn "Factory" src/app/code/Mironsoft/ \
| grep "public function __construct" -A 10 \
| grep "Factory"
10. Fazit: Die goldene Regel des DI
Die Regel ist einfach zu merken:
Goldene Regel
Injiziere nur was stateless oder bewusst geteilt ist.
Models, Collections und DataObjects: immer via Factory erstellen.
✓ Direkt injizieren (Injectable)
- Repositories (ProductRepositoryInterface)
- Factories (*Factory)
- Helpers, Formatters, Validators
- Config (ScopeConfigInterface)
- Logger, EventManager
- StoreManager, UrlInterface
✗ Nie direkt injizieren (Non-Injectable)
- Models (extends AbstractModel)
- DataObjects (extends DataObject)
- Collections (extends AbstractCollection)
- Request/Response-Objekte
- Objekte mit request-spezifischem Zustand
Zusammenfassung
DI-Architektur auditieren
Non-Injectable-Verletzungen finden, Factory-Pattern korrekt implementieren, shared/non-shared konfigurieren.