Plugin / Interceptor Pattern in Magento 2: Before, After und Around erklärt

· Lesezeit: ca. 15 Minuten · Teil der Serie: Design Patterns in Magento 2

Hook
Plugin
Design Pattern #3 · Behavioral (Magento-spezifisch)

Plugin / Interceptor
Pattern in Magento 2

Before, After, Around – Magento-Methoden erweitern ohne Core-Override. Plugin-Chains, sortOrder, Einschränkungen und die wichtigsten Fallstricke vollständig erklärt.

⏱ 15 Min. PHP 8.4 Upgrade-sicher Kein Core Override

Das Plugin Pattern: Magentos einzigartiger Erweiterungsmechanismus

Das Plugin Pattern – in Magento 2 auch als Interceptor Pattern bezeichnet – ist wohl das Magento-spezifischste aller Design Patterns. Es löst ein Problem, das in großen Systemen mit vielen gleichzeitig installierten Modulen unvermeidlich entsteht: Wie können mehrere Module dasselbe Verhalten einer Klasse erweitern, ohne sich gegenseitig zu überschreiben?

Die klassische Lösung – eine Klasse durch eine eigene Subklasse ersetzen (Preference / Override) – versagt hier, weil nur eine Klasse gleichzeitig aktiv sein kann. Magento 2 löst dieses Problem mit einem automatisch generierten Proxy-Mechanismus: Für jede Klasse mit Plugins generiert Magento eine Interceptor-Klasse, die alle registrierten Plugins in einer Chain aufruft.

1. Wie Plugins intern funktionieren

Wenn bin/magento setup:di:compile ausgeführt wird, generiert Magento für jede Klasse mit Plugins eine Interceptor-Klasse. Diese erbt von der originalen Klasse und überschreibt alle betroffenen Methoden. Die generierte Klasse liegt unter generated/code/.

Statt der originalen Klasse wird immer die Interceptor-Klasse instanziiert. Die Interceptor-Klasse ruft alle registrierten Before-Plugins, dann die ursprüngliche Methode, dann alle After-Plugins auf – in der konfigurierten Reihenfolge (sortOrder). Around-Plugins umschließen diesen gesamten Aufruf.


<?php
// Simplified generated Interceptor — what Magento generates automatically:
// generated/code/Magento/Catalog/Api/ProductRepositoryInterface/Interceptor.php

class Interceptor extends ProductRepository
{
    public function getById(int $productId, bool $editMode = false): ProductInterface
    {
        // 1. Run all Before-Plugins
        $pluginInfo = $this->pluginList->getNext($this->subjectType, 'getById');
        if (!$pluginInfo) {
            return parent::getById($productId, $editMode);
        }

        // 2. Call plugin chain (before → original → after)
        return $this->___callPlugins('getById', func_get_args(), $pluginInfo);
    }
}

2. Before Plugin: Argumente modifizieren

Ein Before Plugin wird vor der originalen Methode ausgeführt. Die Plugin-Methode heißt before + MethodenName (PascalCase). Sie empfängt das Subject (das Original-Objekt) und die Argumente der Original-Methode. Der Rückgabewert ist ein Array mit den (möglicherweise modifizierten) Argumenten.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;

/**
 * Before Plugin: Normalizes product SKU to uppercase before any repository lookup.
 */
class NormalizeSkuPlugin
{
    /**
     * Intercepts ProductRepository::get() before it executes.
     *
     * @return array modified arguments [string $sku]
     */
    public function beforeGet(
        ProductRepositoryInterface $subject,
        string $sku,
        bool $editMode = false,
        ?int $storeId = null,
        bool $forceReload = false
    ): array {
        // Return array of modified arguments — order must match original method signature
        return [strtoupper(trim($sku)), $editMode, $storeId, $forceReload];
    }

    /**
     * Intercepts ProductRepository::save() before it executes.
     * Example: enforce SKU format before saving.
     *
     * @return array|null return null to NOT modify arguments
     */
    public function beforeSave(
        ProductRepositoryInterface $subject,
        \Magento\Catalog\Api\Data\ProductInterface $product
    ): ?array {
        if (!$product->getSku()) {
            // Auto-generate SKU if missing
            $product->setSku('AUTO-' . uniqid());
        }

        return [$product]; // return modified argument
    }
}

Wichtig: Before Plugins müssen ein Array zurückgeben (mit den möglicherweise modifizierten Argumenten) oder null, wenn keine Änderung vorgenommen wird. Die Reihenfolge im Array muss der Original-Methoden-Signatur entsprechen.

3. After Plugin: Rückgabewert modifizieren

Ein After Plugin wird nach der originalen Methode ausgeführt. Die Plugin-Methode heißt after + MethodenName. Sie empfängt das Subject, den Rückgabewert der Originalmethode ($result) und optional die Argumente der Originalmethode (seit Magento 2.2).


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;

/**
 * After Plugin: Enriches product data with additional information after loading.
 */
class EnrichProductDataPlugin
{
    public function __construct(
        private readonly \Mironsoft\Catalog\Service\StockBadgeService $stockBadgeService
    ) {}

    /**
     * Intercepts ProductRepository::getById() after it completes.
     *
     * @param ProductInterface $result the return value of the original method
     * @return ProductInterface potentially modified result
     */
    public function afterGetById(
        ProductRepositoryInterface $subject,
        ProductInterface $result,
        int $productId,          // Original method arguments available since Magento 2.2
        bool $editMode = false
    ): ProductInterface {
        // Add custom data to the returned product object
        $hasBadge = $this->stockBadgeService->productHasSaleBadge($result);
        $result->setCustomAttribute('has_sale_badge', $hasBadge ? '1' : '0');

        return $result;
    }

    /**
     * After Plugin on getList: filter out out-of-stock items for guests.
     */
    public function afterGetList(
        ProductRepositoryInterface $subject,
        \Magento\Catalog\Api\Data\ProductSearchResultsInterface $result
    ): \Magento\Catalog\Api\Data\ProductSearchResultsInterface {
        // Filter items in the search results
        $filteredItems = array_filter(
            $result->getItems(),
            fn(ProductInterface $p) => $p->isSaleable()
        );
        $result->setItems(array_values($filteredItems));

        return $result;
    }
}

4. Around Plugin: Methode vollständig umschließen

Ein Around Plugin umschließt die gesamte Methode – inklusive aller anderen Plugins. Es entscheidet selbst, ob und wie die ursprüngliche Methode (oder die nächste Plugin-Stufe) aufgerufen wird. Die Plugin-Methode heißt around + MethodenName und erhält als zweites Argument ein Callable $proceed, das den nächsten Schritt in der Chain repräsentiert.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Psr\Log\LoggerInterface;

/**
 * Around Plugin: Adds timing/profiling around product repository calls.
 * Use Around only when you need to control whether the original executes.
 */
class ProfileProductLoadPlugin
{
    public function __construct(
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Around Plugin for ProductRepository::getById().
     *
     * @param callable $proceed calls the next plugin or the original method
     */
    public function aroundGetById(
        ProductRepositoryInterface $subject,
        callable $proceed,
        int $productId,
        bool $editMode = false,
        ?int $storeId = null,
        bool $forceReload = false
    ): ProductInterface {
        $start = microtime(true);

        try {
            // MUST call $proceed() to execute the original (and other plugins)!
            $result = $proceed($productId, $editMode, $storeId, $forceReload);
        } catch (\Exception $e) {
            // Around plugin can also catch and handle exceptions
            $this->logger->error('Product load failed', [
                'product_id' => $productId,
                'error' => $e->getMessage(),
            ]);
            throw $e; // rethrow — don't swallow exceptions silently
        }

        $duration = microtime(true) - $start;
        if ($duration > 0.1) {
            $this->logger->warning('Slow product load', [
                'product_id' => $productId,
                'duration_ms' => round($duration * 1000, 2),
            ]);
        }

        return $result;
    }
}

Warnung: Around Plugins, die $proceed() nicht aufrufen, unterbrechen die gesamte Plugin-Chain. Das ist manchmal gewünscht (z.B. Caching, Feature-Flags), aber gefährlich: Andere Module in der Chain werden nicht mehr ausgeführt. Around Plugins sparsam einsetzen – Before und After reichen in den meisten Fällen.

5. Plugin Registrierung in di.xml


<!-- app/code/Mironsoft/Catalog/etc/di.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <type name="Magento\Catalog\Api\ProductRepositoryInterface">

        <!-- Before Plugin: normalize SKU -->
        <plugin name="mironsoft_normalize_sku"
                type="Mironsoft\Catalog\Plugin\NormalizeSkuPlugin"
                sortOrder="10"/>

        <!-- After Plugin: enrich product data -->
        <plugin name="mironsoft_enrich_product"
                type="Mironsoft\Catalog\Plugin\EnrichProductDataPlugin"
                sortOrder="20"/>

        <!-- Around Plugin: profiling (disabled in production via config) -->
        <plugin name="mironsoft_profile_product_load"
                type="Mironsoft\Catalog\Plugin\ProfileProductLoadPlugin"
                sortOrder="100"
                disabled="true"/>

    </type>

    <!-- Plugin on a specific scope only (frontend) -->
    <!-- In etc/frontend/di.xml: -->
    <!-- <type name="..."><plugin name="..." type="..." sortOrder="..."/></type> -->
</config>

6. sortOrder: Plugin-Reihenfolge präzise steuern

Das sortOrder-Attribut bestimmt die Reihenfolge, in der mehrere Plugins auf derselben Methode ausgeführt werden. Kleinere Zahlen werden zuerst ausgeführt. Das ist besonders wichtig, wenn Plugins voneinander abhängen oder wenn ein Plugin eine Änderung rückgängig machen könnte, die ein anderes vorgenommen hat.

Plugin-Chain Ausführungsreihenfolge bei sortOrder 10, 20, 100 before (10) NormalizeSku before (20) AutoSku Original Methode after (20) EnrichProduct after (100) Profiler Before-Plugins: kleinste sortOrder zuerst After-Plugins: kleinste sortOrder zuerst

7. Plugin-Chain: Mehrere Plugins auf einer Methode

Mehrere Module können Plugins auf dieselbe Methode registrieren. Magento führt sie alle aus – in der Reihenfolge ihrer sortOrder. Kein Modul muss wissen, ob andere Module ebenfalls Plugins registriert haben.


<?php
// Plugin A von Modul 1 (sortOrder=10):
public function beforeSave(ProductRepositoryInterface $subject, ProductInterface $product): array
{
    $product->setData('enriched_by_module_a', true);
    return [$product];
}

// Plugin B von Modul 2 (sortOrder=20) sieht die Änderung von Plugin A:
public function beforeSave(ProductRepositoryInterface $subject, ProductInterface $product): array
{
    if ($product->getData('enriched_by_module_a')) {
        $product->setData('double_enriched', true);
    }
    return [$product];
}

// Reihenfolge der Ausführung:
// 1. Plugin A before (sortOrder=10)
// 2. Plugin B before (sortOrder=20)
// 3. Original save() Methode
// 4. Plugin B after (sortOrder=20) — After-Reihenfolge: niedrigste zuerst
// 5. Plugin A after (sortOrder=10)

8. Einschränkungen: Wann Plugins nicht funktionieren

Plugins haben klare Grenzen. Es ist wichtig, diese zu kennen, um nicht Zeit mit einem nicht funktionierenden Plugin zu verlieren:

  • Finale Klassen (final class): Können nicht von Interceptors beerbt werden → kein Plugin möglich.
  • Finale Methoden (final function): Können nicht überschrieben werden → kein Plugin möglich.
  • Private Methoden: Private Methoden sind nicht Teil der Klassen-API → kein Plugin.
  • Static Methoden: Statische Methoden werden nicht intercepted.
  • Konstruktoren: __construct() kann nicht mit Plugins belegt werden.
  • Direkte ObjectManager-Aufrufe: Klassen, die direkt via ObjectManager instanziiert werden (ohne DI), können keine Plugins haben.

<?php
// Diese Methoden können NICHT mit Plugins belegt werden:

final class UnpluggableClass  // final class → kein Plugin
{
    public function doSomething(): void {}
}

class SomeClass
{
    final public function alsoUnpluggable(): void {}  // final method → kein Plugin
    private function alsoNotPluggable(): void {}       // private → kein Plugin
    public static function staticNotPluggable(): void {} // static → kein Plugin
}

// Lösung wenn Plugin nicht funktioniert:
// → Observer/Event verwenden
// → Preference (letzter Ausweg)
// → Klasse refactoren (falls eigener Code)

9. Praxisbeispiele aus dem Magento-Alltag

Beispiel 1: Produkt-URL nach Speichern leeren (Cache-Invalidierung)


<?php
namespace Mironsoft\SeoTools\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\App\Cache\TypeListInterface;

class InvalidateProductUrlCachePlugin
{
    public function __construct(
        private readonly TypeListInterface $cacheTypeList
    ) {}

    /**
     * After saving a product, invalidate the URL and FPC cache.
     */
    public function afterSave(
        ProductRepositoryInterface $subject,
        ProductInterface $result
    ): ProductInterface {
        // Invalidate Full Page Cache after product save
        $this->cacheTypeList->invalidate(['full_page', 'block_html']);
        return $result;
    }
}

Beispiel 2: Gastbestellungen verhindern (Feature Flag)


<?php
namespace Mironsoft\B2B\Plugin;

use Magento\Checkout\Model\Type\Onepage;
use Magento\Customer\Model\Session;

class RequireLoginForCheckoutPlugin
{
    public function __construct(
        private readonly Session $customerSession,
        private readonly \Mironsoft\B2B\Helper\Config $config
    ) {}

    /**
     * Around Plugin: prevent guest checkout when B2B mode is enabled.
     */
    public function aroundGetCheckoutMethod(
        Onepage $subject,
        callable $proceed
    ): string {
        if ($this->config->isB2BModeEnabled() && !$this->customerSession->isLoggedIn()) {
            // Return 'login_in' to redirect to login — do NOT call $proceed()
            return Onepage::METHOD_REGISTER;
        }

        return $proceed();
    }
}

10. Plugin vs. Preference vs. Observer – die Entscheidungsmatrix

Plugin vs. Preference vs. Observer – wann was? Kriterium Plugin Observer Preference Rückgabewert ändern Argumente modifizieren Mehrere Module gleichzeitig ✗ (nur 1) Upgrade-sicher ✗ (riskant) Kein Event notwendig ✗ (braucht Event)

Mironsoft

Magento 2 Modul-Entwicklung

Magento Core-Override bereinigen?

Bestehende Preferences und Core-Overrides durch saubere Plugins ersetzen – für upgrade-sichere Magento 2 Erweiterungen ohne Konflikte.

Plugin-Audit
Bestehende Core-Overrides und Preferences analysieren und durch saubere Plugins ersetzen.
Plugin-Migration
Preference-basierte Module auf Before/After Plugins umstellen für Upgrade-Sicherheit.
Plugin-Tests
PHPUnit Integration-Tests für Plugin-Chains mit vollständiger Abdeckung aller Szenarien.

11. Zusammenfassung

Das Plugin Pattern ist Magentos leistungsfähigster Erweiterungsmechanismus. Before, After und Around Plugins ermöglichen es, das Verhalten beliebiger öffentlicher Methoden zu erweitern – ohne Core-Dateien zu bearbeiten, ohne andere Module zu blockieren und ohne Upgrade-Risiken.

Plugin Pattern in Magento 2 – Regeln auf einen Blick

Before Plugin

Argumente modifizieren. Gibt Array mit Argumenten zurück (oder null für keine Änderung). Methoden-Name: before + MethodeName.

After Plugin

Rückgabewert modifizieren. Zweites Argument ist $result. Gibt modifizierten Result zurück. Methoden-Name: after + MethodeName.

Around Plugin

Immer $proceed() aufrufen außer bei bewusstem Abbruch. Zweites Argument ist callable $proceed. Sparsam einsetzen.

Einschränkungen

Nicht möglich auf: final class, final method, private method, static method, __construct. Alternative: Observer oder Preference.

12. FAQ: Plugin Pattern in Magento 2

1 Warum funktioniert mein Plugin nicht?
Checkliste: (1) bin/magento setup:di:compile && cache:flush. (2) Methode nicht final/private/static? (3) Methodenname korrekt? (beforeGet, nicht before_get). (4) Plugin nicht als disabled="true"? (5) Modul aktiv? (6) Klasse via DI instanziiert (nicht via new)?
2 Around Plugin ohne $proceed – was passiert?
Die ursprüngliche Methode und alle nachfolgenden Plugins werden nicht ausgeführt. Manchmal gewollt (Feature-Flag, Caching), aber gefährlich: andere Module in der Chain werden übergangen. Immer mit Bedacht und explizit dokumentieren warum $proceed nicht aufgerufen wird.
3 Kann ich Plugins für eigene Klassen erstellen?
Ja! Plugins funktionieren nicht nur auf Core-Klassen, sondern auf jeder Klasse – auch auf eigenen. Das ermöglicht z.B. ein „Audit-Plugin", das alle Repository-Saves logt, ohne den Repository-Code zu ändern. Einzige Bedingung: Die Klasse muss via DI instanziiert werden.
4 Was ist der Unterschied zwischen Before, After und Around?
Before: läuft zuerst, kann Argumente ändern (Array zurückgeben). After: läuft nach der Methode, erhält $result und kann ihn ändern. Around: umschließt alles, $proceed() ruft die Chain weiter auf. Faustregel: Before/After reichen für 90% der Fälle.
5 Kann ich Plugins auf Interfaces statt Klassen registrieren?
Ja – und das ist bevorzugt. Ein Plugin auf ProductRepositoryInterface greift auf alle Implementierungen. Stabiler als konkreter Klassenname, der sich durch Preferences ändern kann. In di.xml: <type name="Magento\Catalog\Api\ProductRepositoryInterface">.
6 Wie teste ich ein Plugin mit PHPUnit?
Plugin-Methode direkt aufrufen: Subject als Mock, für After-Plugins $result als Mock übergeben. Für Around-Plugins $proceed als Closure mocken und prüfen ob sie aufgerufen wurde. Kein DI-Compile nötig — Plugin-Klasse direkt instanziieren mit gemockten Dependencies.
7 Was passiert wenn zwei Module dasselbe Plugin registrieren?
Beide werden ausgeführt — das ist das Design. sortOrder bestimmt die Reihenfolge. Plugin-Namen müssen eindeutig sein: gleiche Namen überschreiben sich. Empfehlung: eindeutige Namen mit Modul-Präfix (vendor_module_pluginname).
8 Kann ich ein Plugin deaktivieren ohne Code-Änderung?
Ja — in der di.xml des eigenen Moduls den Plugin-Namen mit disabled="true" eintragen. Funktioniert auch für Plugins anderer Module. Scope-spezifisch: in etc/frontend/di.xml nur im Frontend deaktivieren.
9 Wie finde ich aktive Plugins auf einer Methode?
bin/magento dev:di:info 'Magento\Catalog\Api\ProductRepositoryInterface' listet alle Plugins. Alternativ: generierte Interceptor-Datei lesen (generated/code/). Oder: grep -r 'plugin name' app/code/ --include="di.xml".
10 Gibt es Performance-Unterschiede zwischen den Plugin-Typen?
Around Plugins haben den höchsten Overhead (Closure-Kette). Before/After sind leichter. In der Praxis ist der Unterschied minimal — der eigentliche Faktor ist die Logik im Plugin. Vorsicht: viele Around Plugins auf häufig aufgerufenen Methoden (z.B. getById in Schleifen) können spürbar werden.