Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Plugins (Interceptors) in Detail: before/after/around

Plugins (Interceptors) in Detail: before/after/around

~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Chapter 37 drew the line: observers can react, but they can never change a method's return value. Block 5 picks up exactly there - with plugins (officially: interceptors), Magento's mechanism for actively intervening in an existing method without touching its source code. This chapter clarifies the mechanics in detail before chapter 39 writes this series' first production plugin.

What actually happens with a plugin?

When di.xml registers a plugin for a class or interface, Magento generates an interceptor class under generated/code/ on first use (or ahead of time via bin/magento setup:di:compile). That interceptor class extends the target class and overrides exactly the plugged method - the overridden version internally calls ___callPlugins(), which runs every registered before, around, and after method in the order determined by sortOrder (chapter 43 goes deeper into the exact execution order).

Because a plugin is technically a subclass override, the same rules apply as for any ordinary PHP inheritance:

  • The target method must be public - PHP simply cannot override private or protected methods.
  • The target method must not be final - overriding a final method is a PHP fatal error, not just a Magento limitation (chapters 40/41 show how to actually handle that case).
  • The target method must not be static - static calls don't go through the DI container's instance, so they never pass through the interceptor.
  • The target class must be created through the DI container (constructor injection or ObjectManager::create()/get()) - a hard-coded new Class() at the call site bypasses the interceptor entirely.

before plugins: intercepting and changing arguments

A before method is named before<MethodName>, receives $subject (the original instance) as its first parameter followed by the same parameters as the target method, and returns either null (leave the arguments unchanged) or an array with the - possibly changed - arguments in original order. This chapter's running example is PointsLedgerRepositoryInterface::getListByCustomerId() from chapter 6.

app/code/Mironsoft/Loyalty/Plugin/PointsLedger/ValidateCustomerIdBeforeGetListPlugin.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Plugin\PointsLedger;

use Magento\Framework\Exception\LocalizedException;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;

/**
 * Rejects clearly invalid customer IDs before the repository even queries the
 * database - a cheap, in-memory guard applied to every caller of this method.
 */
class ValidateCustomerIdBeforeGetListPlugin
{
    /**
     * Validates the customer ID argument before getListByCustomerId() runs.
     *
     * @param PointsLedgerRepositoryInterface $subject The intercepted repository instance.
     * @param int $customerId Customer entity ID as passed by the caller.
     * @return array{0: int}|null
     * @throws LocalizedException
     */
    public function beforeGetListByCustomerId(
        PointsLedgerRepositoryInterface $subject,
        int $customerId
    ): ?array {
        if ($customerId <= 0) {
            throw new LocalizedException(__('Customer ID must be a positive integer.'));
        }

        return null;
    }
}

Returning null here explicitly means "leave the arguments unchanged" - a before plugin doesn't have to touch the arguments to be useful; pure validation like this is a legitimate use case on its own.

after plugins: changing return values

An after method is named after<MethodName>, receives $subject as its first parameter, the already computed return value of the original method as its second parameter ($result), optionally followed by the same arguments as the target method, and must return the - possibly changed - return value itself.

app/code/Mironsoft/Loyalty/Plugin/PointsLedger/LimitLedgerListResultPlugin.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Plugin\PointsLedger;

use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;

/**
 * Caps the number of ledger entries returned to any caller as a defensive
 * safety net - protects memory if a customer accumulates an unusually long
 * history and a future caller forgets to paginate.
 */
class LimitLedgerListResultPlugin
{
    private const MAX_ENTRIES = 500;

    /**
     * Truncates the ledger list to MAX_ENTRIES most recent entries.
     *
     * @param PointsLedgerRepositoryInterface $subject The intercepted repository instance.
     * @param \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[] $result Entries returned by the original method.
     * @param int $customerId Customer entity ID as passed by the caller.
     * @return \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[]
     */
    public function afterGetListByCustomerId(
        PointsLedgerRepositoryInterface $subject,
        array $result,
        int $customerId
    ): array {
        return \array_slice($result, 0, self::MAX_ENTRIES);
    }
}

The third parameter ($customerId) goes unused here but is still mandatory in the signature - after plugins always receive the original arguments too, even when they don't need them.

around plugins: fully controlling execution

An around method is named around<MethodName>, receives a callable $proceed as its second parameter - a call into the next layer of the plugin chain, or ultimately into the original method - and decides for itself whether, when, and how often $proceed() gets called. That makes around the most powerful, and also the most expensive, of the three types.

app/code/Mironsoft/Loyalty/Plugin/PointsLedger/CacheLedgerListPlugin.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Plugin\PointsLedger;

use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;

/**
 * Caches the ledger list per customer ID for the duration of the current
 * request - the account page (chapter 48) and the checkout totals plugin
 * (chapter 39) can both ask for the same customer's history without
 * triggering two identical database queries.
 */
class CacheLedgerListPlugin
{
    /**
     * @var array<int, \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[]>
     */
    private array $requestCache = [];

    /**
     * Serves cached results within the same request, delegates to the original
     * method on a cache miss and stores the result for subsequent calls.
     *
     * @param PointsLedgerRepositoryInterface $subject The intercepted repository instance.
     * @param callable $proceed Invokes the next plugin layer or the original method.
     * @param int $customerId Customer entity ID as passed by the caller.
     * @return \Mironsoft\Loyalty\Api\Data\PointsLedgerInterface[]
     */
    public function aroundGetListByCustomerId(
        PointsLedgerRepositoryInterface $subject,
        callable $proceed,
        int $customerId
    ): array {
        if (!isset($this->requestCache[$customerId])) {
            $this->requestCache[$customerId] = $proceed($customerId);
        }

        return $this->requestCache[$customerId];
    }
}

Wiring it up in di.xml

app/code/Mironsoft/Loyalty/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="Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface">
        <plugin name="mironsoft_loyalty_validate_customer_id_before_get_list"
                type="Mironsoft\Loyalty\Plugin\PointsLedger\ValidateCustomerIdBeforeGetListPlugin"
                sortOrder="10"/>
        <plugin name="mironsoft_loyalty_cache_ledger_list"
                type="Mironsoft\Loyalty\Plugin\PointsLedger\CacheLedgerListPlugin"
                sortOrder="20"/>
        <plugin name="mironsoft_loyalty_limit_ledger_list_result"
                type="Mironsoft\Loyalty\Plugin\PointsLedger\LimitLedgerListResultPlugin"
                sortOrder="30"/>
    </type>
</config>

Tipp: The name value of a <plugin> entry must be unique across the whole project - otherwise a later declaration with an identical name silently overwrites the earlier one. The mironsoft_loyalty_<verb> convention from chapter 30/31 (observer names) carries over one-to-one to plugin names.

Achtung: around plugins are noticeably more expensive than before/after: every layer adds an extra function call, and $proceed() generally must be called exactly once - forget it, and the original method never runs; call it twice, and it runs twice. Rule of thumb: if before or after is enough, around is the wrong choice. That's exactly why chapter 39 deliberately reaches for after, even though around might look tempting at first glance.

With the mechanics in the toolbox, chapter 39 implements this series' first real production plugin: a points discount that actually flows into the order total during checkout.