DI
FACTORY
Deep Dive · Magento 2 Dependency Injection

Injectable vs. Non-Injectable:
Die unterschätzte Grenze in Magento

Warum Models und DataObjects nicht in Konstruktoren injiziert werden dürfen, wie Factories das lösen, und was genau der DI-Container als "injectable" einstuft.

12 min Lesezeit
Magento 2.4.8 · PHP 8.4
Magento DI-Konzepte

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\AbstractModel erweitern (alle Models)
  • Magento\Framework\DataObject erweitern (DataObjects)
  • Magento\Framework\Data\Collection erweitern (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

Injectable
Services, Repositories, Factories, Config — zustandslos oder bewusst geteilt — sicher im Konstruktor
Non-Injectable
Models, DataObjects, Collections — request-spezifischer Zustand — immer via Factory in Methoden erstellen
Factory
create() ≠ get() — Factory::create() erzeugt immer neue Instanz, get() gibt Singleton zurück
shared in di.xml
shared="false" für Services mit eigenem Zustand; Collections und Models sind immer non-shared

DI-Architektur auditieren

Non-Injectable-Verletzungen finden, Factory-Pattern korrekt implementieren, shared/non-shared konfigurieren.

????
DI-Audit
Alle Non-Injectable-Verletzungen im Codebase finden
????
Factory-Refactoring
Direkte Model-Injektionen durch Factories ersetzen
????
PHPStan Level 8
Statische Analyse für Injectable-Fehler einrichten

Häufige Fragen zu Injectable vs. Non-Injectable in Magento

Was sind Injectable und Non-Injectable Objects? +
Injectable sind Services, Repositories, Factories — zustandslos oder bewusst geteilt — sicher im Konstruktor. Non-Injectable sind Models, DataObjects, Collections — request-spezifischer Zustand — nur via Factory in Methoden erstellen.
Warum darf ich ein Magento-Model nicht direkt injizieren? +
Der DI-Container verwaltet Injectable Objects als Singletons — dieselbe Instanz wird geteilt. Ein Model mit request-spezifischem Zustand würde von allen Nutzern des Service geteilt, was zu Datenlecks führt.
Wie erstelle ich ein Model korrekt ohne direkte Injektion? +
ProductFactory statt Product im Konstruktor injizieren. Dann $this->productFactory->create() in der Methode aufrufen. create() erstellt immer eine neue Instanz (nie shared).
Was generiert Magento automatisch für Factories? +
Magento generiert automatisch Factory-Klassen in generated/code/ wenn du {ClassName}Factory anforderst. Die generierte Klasse hat create() das ObjectManager::create() (nicht get()) aufruft — immer neue Instanz.
Was ist der Unterschied zwischen ObjectManager::get() und create()? +
get() gibt immer dieselbe (shared) Instanz zurück. create() erstellt immer eine neue Instanz. Factory::create() ruft intern create() auf, nicht get().
Was bedeutet shared='false' in di.xml? +
Statt immer dieselbe Instanz zurückzugeben, wird bei jedem Aufruf eine neue Instanz erstellt. Sinnvoll für Services die Zustand akkumulieren. Collections sollten immer shared='false' sein.
Was ist ein Magento Proxy? +
Eine automatisch generierte Wrapper-Klasse die ein teures Injectable Object lazy initialisiert — erst beim ersten Methodenaufruf. In di.xml als argument mit dem Proxy-Klassenname konfigurierbar.
Wie erkennt PHPStan Non-Injectable-Verletzungen? +
Mit magento/magento-coding-standard und dem PHPStan-Extension erkennt PHPStan direkte Injektionen von Models im Konstruktor. Fehlermeldung: 'Class X is non-injectable. Use factory instead.'
Sind alle Service-Klassen automatisch Injectable? +
Nicht alle — Services die eigenen Zustand akkumulieren sollten shared='false' sein. Reine stateless Services sind standardmäßig shared='true' und sicher injectable.
Kann ich eine eigene Factory-Klasse schreiben? +
Ja. Sinnvoll wenn create() nicht ausreicht — z.B. für domänenspezifische Objekte mit vordefinierten Defaults. Die auto-generierte Factory injizieren und Factory-Methoden wie createPublished() hinzufügen.