21/11/2024 8 Minutes read Tech 

Magento 2 interceptor illustration
Magento 2 interceptor illustration

One of the most commonly used design pattern in Magento 2, is the interceptor design pattern, often referred to as a plugin in Magento terminology.
It’s an AOP (Aspect-Oriented Programming) approach.

An interceptor allows you to modify the behavior of a method by intercepting the method call and executing code either before or after the method call.
Magento 2 extends this design pattern by introducing the ability to run “around” code, which allows you to completely override the original behavior of the method.

schema illustrate the Magento 2 interceptor design pattern in an application process
Interceptor design pattern on Magento 2

To manage interceptors, an additional layer of abstraction is required above the existing code.
It’s why only public methods can be intercepted on Magento 2.
As we will see, a child class with same method name will add a new workflow for plugins call, before, around, and after the parent method, and must be able to invoke the parent method.

With the help from its code generators, Magento 2 has create a great develop experience. As developers, our task is straightforward: create the plugin class that contains the code for altering behavior, and then register this class in the di.xml file.

Create an interceptor on Magento 2

Let’s create an example of a plugin that catches an LocalizedException when the Magento Area Code is set multiple times.

The plugin declaration in the di.xml:

<type name="MagentoFrameworkAppState">  
<plugin name="plugin_alter_set_area_code" type="VendorModulePluginState"/>
</type>

The plugin class:

namespace VendorModulePlugin;  

use MagentoFrameworkExceptionLocalizedException;

/**
* Plugin to catch exception when state is set multiple times or unset
*/
final class State
{
public function aroundSetAreaCode(MagentoFrameworkAppState $subject, callable $proceed, $code): void
{
try {
$proceed($code);
} catch (LocalizedException $e) {}
}
}

And that’s it! After compilation, our plugin is now working. It doesn’t alter the original behavior but simply catches LocalizedException during the execution of the callable (the parent method).

Magento 2 implementation of this design pattern with code generator

Great but how is Magento able to automatically call the child method aroundSetAreaCode instead of setAreaCode like before?

Let’s take a look at the backtrace returned inside the aroundSetAreaCode method when it is executed:

#0 /var/www/html/vendor/magento/framework/Interception/Interceptor.php(135): VendorModulePluginState->aroundSetAreaCode() 
#1 /var/www/html/vendor/magento/framework/Interception/Interceptor.php(153): MagentoFrameworkAppStateInterceptor->MagentoFrameworkInterception{closure}()
#2 /var/www/html/generated/code/Magento/Framework/App/State/Interceptor.php(23): MagentoFrameworkAppStateInterceptor->___callPlugins()
#3 /var/www/html/vendor/magento/framework/App/Http.php(112): MagentoFrameworkAppStateInterceptor->setAreaCode()

Going back in the order of execution, we notice that the Magento/Framework/App/Http.php class calls on line 112 the setAreaCode method, of the Magento/Framework/App/State/Interceptor.php class.
Next interceptor class is going to call all the plugins through the __callPlugins method.

However, if we look at the code of the Magento/Framework/App/Http.php class, we notice the call to the setAreaCode method, from the Magento/Framework/App/State.php source class and not from the linked interceptor class:

namespace MagentoFrameworkApp;
...
class Http implements MagentoFrameworkAppInterface
{
...
public function __construct(
ObjectManagerInterface $objectManager,
Manager $eventManager,
AreaList $areaList,
RequestHttp $request,
ResponseHttp $response,
ConfigLoaderInterface $configLoader,
State $state,
Registry $registry,
ExceptionHandlerInterface $exceptionHandler = null
) {
$this->_objectManager = $objectManager;
$this->_eventManager = $eventManager;
$this->_areaList = $areaList;
$this->_request = $request;
$this->_response = $response;
$this->_configLoader = $configLoader;
$this->_state = $state;
$this->registry = $registry;
$this->exceptionHandler = $exceptionHandler ?: $this->_objectManager->get(ExceptionHandlerInterface::class);
}
...
public function launch()
{
...
$this->_state->setAreaCode($areaCode);
...
}
...
}

Continuing the backtrace, we realize that the Magento/Framework/App/Http.php class was called itself by its interceptor.

#0 /var/www/html/vendor/magento/framework/Interception/Interceptor.php(135): EkinoSwoolePluginState->aroundSetAreaCode() 
#1 /var/www/html/vendor/magento/framework/Interception/Interceptor.php(153): MagentoFrameworkAppStateInterceptor->MagentoFrameworkInterception{closure}()
#2 /var/www/html/generated/code/Magento/Framework/App/State/Interceptor.php(23): MagentoFrameworkAppStateInterceptor->___callPlugins()
#3 /var/www/html/vendor/magento/framework/App/Http.php(112): MagentoFrameworkAppStateInterceptor->setAreaCode()
#4 /var/www/html/vendor/magento/framework/Interception/Interceptor.php(58): MagentoFrameworkAppHttp->launch()
#5 /var/www/html/vendor/magento/framework/Interception/Interceptor.php(138): MagentoFrameworkAppHttpInterceptor->___callParent()

Once again the arguments of the Magento/Framework/App/Http/Interceptor.php constructor always include the Magento/Framework/App/State.php class instead of its interceptor class:

namespace MagentoFrameworkAppHttp;  

/**
* Interceptor class for @see MagentoFrameworkAppHttp
*/
class Interceptor extends MagentoFrameworkAppHttp implements MagentoFrameworkInterceptionInterceptorInterface
{
use MagentoFrameworkInterceptionInterceptor;

public function __construct(
MagentoFrameworkObjectManagerInterface $objectManager,
MagentoFrameworkEventManager $eventManager,
MagentoFrameworkAppAreaList $areaList,
MagentoFrameworkAppRequestHttp $request,
MagentoFrameworkAppResponseHttp $response,
MagentoFrameworkObjectManagerConfigLoaderInterface $configLoader,
MagentoFrameworkAppState $state,
MagentoFrameworkRegistry $registry,
?MagentoFrameworkAppExceptionHandlerInterface $exceptionHandler = null
) {
$this->___init();
parent::__construct($objectManager, $eventManager, $areaList, $request, $response, $configLoader, $state, $registry, $exceptionHandler);
}
}

It’s in fact, another step upstream, when the objectManager of Magento, in Magento/Framework/ObjectManager/Factory/Compiled.php, use the create method for creating the Magento/Framework/App/Http/Interceptor object.
Inside this method, a createObject of the interceptor is made, using as constructor arguments the arguments coming from the metadata files in the generated folder.

As we can see in the Adobe developer documentation, Magento uses an objectManager to avoid boilerplate code when composing objects during instantiation.

The metadata files in the generated folder are created during the di:compile command as I will describe in another article. These files take into account all configuration files that define plugins, preferences, and other design patterns within modules and Area codes.

Inside one of the metadata files generated, the global.php file which contains all the configuration for constructors at global Area of Magento, we see this arguments for the Magento/Framework/App/Http/Interceptor class:

'Magento\Framework\App\Http\Interceptor' =>  
[
'objectManager' =>
[
'_i_' => 'Magento\Framework\ObjectManagerInterface',
]
'eventManager' =>
[
'_i_' => 'Magento\Framework\Event\Manager',
]
'areaList' =>
[
'_i_' => 'Magento\Framework\App\AreaList',
]
'request' =>
[
'_i_' => 'Magento\Framework\App\Request\Http',
]
'response' =>
[
'_i_' => 'Magento\Framework\App\Response\Http\Interceptor',
]
'configLoader' =>
[
'_i_' => 'Magento\Framework\ObjectManager\ConfigLoaderInterface',
]
'state' =>
[
'_i_' => 'Magento\Framework\App\State\Interceptor',
]
'registry' =>
[
'_i_' => 'Magento\Framework\Registry',
]
'exceptionHandler' =>
[
'_vn_' => true,
]
]

We can note the use of the interceptor for State class.

And the create method of the objectManager finds them with the getArguments config method and use it for the createObject method:

public function create($requestedType, array $arguments = [])  
{
$args = $this->config->getArguments($requestedType);
$type = $this->config->getInstanceType($requestedType);

if ($args === []) {
// Case 1: no arguments required
return new $type();
} elseif ($args !== null) {
/**
* Case 2: arguments retrieved from pre-compiled DI cache
*
* Argument key meanings:
*
* _i_: shared instance of a class or interface
* _ins_: non-shared instance of a class or interface
* _v_: non-array literal value
* _vac_: array, may be nested and contain other types of keys listed here (objects, array, nulls, etc)
* _vn_: null value
* _a_: value to be taken from named environment variable
* _d_: default value in case environment variable specified by _a_ does not exist
*/
foreach ($args as $key => &$argument) {
if (isset($arguments[$key])) {
$argument = $arguments[$key];
} elseif (isset($argument['_i_'])) {
$argument = $this->get($argument['_i_']);
} elseif (isset($argument['_ins_'])) {
$argument = $this->create($argument['_ins_']);
} elseif (isset($argument['_v_'])) {
$argument = $argument['_v_'];
} elseif (isset($argument['_vac_'])) {
$argument = $argument['_vac_'];
$this->parseArray($argument);
} elseif (isset($argument['_vn_'])) {
$argument = null;
} elseif (isset($argument['_a_'])) {
if (isset($this->globalArguments[$argument['_a_']])) {
$argument = $this->globalArguments[$argument['_a_']];
} else {
$argument = $argument['_d_'];
}
}
}
$args = array_values($args);
} else {
// Case 3: arguments retrieved in runtime
$parameters = $this->getDefinitions()->getParameters($type) ?: [];
$args = $this->resolveArgumentsInRuntime(
$type,
$parameters,
$arguments
);
}

return $this->createObject($type, $args);
}

The createObject method uses the constructor promotion to create the target object with the interceptor classes that implement the interceptor design pattern.

Now back to our generated State interceptor class, it contains the following code for the setAreaCode method:

public function setAreaCode($code)  
{
$pluginInfo = $this->pluginList->getNext($this->subjectType, 'setAreaCode');
return $pluginInfo ? $this->___callPlugins('setAreaCode', func_get_args(), $pluginInfo) : parent::setAreaCode($code);
}

The getNext method of the pluginList object use another metadata file in the generated folder, the primary|global|AREA|plugin-list.php, that contains list of interceptors for each classes.

For the Magento/Framework/App/State with our example plugin, this is the result in this metadata file:

'Magento\Framework\App\State' =>   
[
'framework-state-newrelic' =>
[
'sortOrder' => 0,
'instance' => 'Magento\NewRelicReporting\Plugin\StatePlugin',
]
'plugin_alter_set_area_code' =>
[
'sortOrder' => 0,
'instance' => 'Vendor\Module\Plugin\State',
]
]

The ___callPlugins method then executes all the necessary plugins with this list of interceptors.

To define the order of execution of the plugins, and in particular to manage the order when you have several plugins on the same method with some in before, others in around or after for example, it’s during the preliminary call of the getNext method, that the inheritPlugins method of the Magento/Framework/Interception/PluginListGenerator class outputs the appropriate result (having also filtered the disabled plugins or sorted with the sortOrder defined).

That is the constants used as keys for plugins array and used in the inheritPlugins method:

const LISTENER_BEFORE = 1;  

const LISTENER_AROUND = 2;

const LISTENER_AFTER = 4;

We have seen how the generated metadata files AREA.php, primary|global|AREA|plugin-list.php, and the generated class Interceptor.php are used.

There remains a metadata file that was generated during compilation and used for interceptor design pattern application on Magento: interception.php.
This allows objectManager to know the instance to create during createObject: the interceptor or not, depending on the presence of plugins on the module, with the use of hasPlugins method:

/**  
* Retrieve instance type with interception processing
*
* @param string $instanceName
* @return string
*/
public function getInstanceType($instanceName)
{
$type = parent::getInstanceType($instanceName);
if ($this->interceptionConfig && $this->interceptionConfig->hasPlugins($type)
&& $this->interceptableValidator->validate($type)
) {
return $type . '\Interceptor';
}
return $type;
}

To summarize, the plugins work thanks to the Magento objectManager which allows you to instantiate the auto-generated interceptors classes, instead of the initial classes, by the createObject method, when a plugin exists on a method of the initial class.

These self-generated classes allow plugins to be inserted before, around, or after the targeted method, it’s the interceptor design pattern.

And this is why only public methods can have a plugin: it allows the interceptor class, which extends the class of the method, to call the original method.

Finally, the metadata files generated during di:compile avoid the discovery of plugins and the plugin tree at runtime.

Compare with PHP and other framework implementation for interceptors

  • Spiral framework have a more structured and elegant way to achieve the interceptor design pattern, with only php objects, no call to generated array of mapping for interceptors like on Magento, and of course no xml files.
    By the way, creating an interceptor is a bit more complex; you need to add your interceptor to the processing flow, and you can only create interceptors on classes that implement CoreInterface. In Magento, you don’t need to manage the processing flow to add a plugin.
use SpiralCoreInterceptableCore;
use AppApplicationDatabaseDatabaseQueryCore;
use CycleDatabaseDatabaseManager;

$core = new InterceptableCore(
new DatabaseQueryCore(new DatabaseManager(...))
);

$core->addInterceptor(new SlowQueryDetectorInterceptor(new Logger(...)));

// Execute a SELECT statement on the 'default' database
$result = $core->callAction(
'default',
'SELECT * FROM users WHERE id = ?',
['sql_parameters' => [1]]
);

Extract of code from this doc:
https://spiral.dev/docs/framework-interceptors/current/en#interceptors

  • Ray.Aop comes with a PECL extension that manages Aspects for method interception. It’s quite easy to make an interceptor with only few lines of code. It’s based on a method_intercept function.
    It’s the equivalent of the around plugin of Magento 2, because you can’t split with specific method only for before or after, initial method execution.
class MyInterceptor implements RayAopMethodInterceptorInterface
{
public function intercept(object $object, string $method, array $params): mixed
{
echo "Before method executionn";
$result = call_user_func_array([$object, $method], $params);
echo "After method executionn";
return $result;
}
}

class TestClass
{
public function testMethod($arg)
{
echo "TestClass::testMethod($arg) calledn";
return "Result: $arg";
}
}

$interceptor = new MyInterceptor();
method_intercept('TestClass', 'testMethod', $interceptor);

$test = new TestClass();
$result = $test->testMethod("test");
echo "Final result: $resultn";

Extract of code from this doc:
https://github.com/ray-di/ext-rayaop?tab=readme-ov-file

  • PHP can also achieve this with decorator and magic method __call.
    However it’s a little different from the initial behavior mentioned in this article regarding the interceptor design pattern; here it’s the decorator design pattern. The difference is that with one interceptor you can intercept methods of multiples classes.
    You can do something similar to plugins with this pattern and the use of __call method.
    Additionally, you need to have control over the processing flow to declare objects that need plugins.
class State 
{
public function setAreaCode() {
// Initial behavior
}
}

class Decorator
{
protected $state;

public function __construct(State $state) {
$this->state = $state;
}

public function __call($method_name, $args) {
// DO something before initial method here
$result = call_user_func_array(array($this->state, $method_name), $args);
// DO something after initial method here
return $result;
}
}

$interceptor = new Decorator(new State());
$interceptor->setAreaCode();

Example of code to intercept method with decorator.

Maybe an implementation of this pattern is also possible with the use of ReflectionClass and Attributes in pure PHP…

Conclusion

Magento 2 has created his own logic to manage an AOP aspect in PHP, and follows the interceptor design pattern. It provides great flexibility to modify the initial behavior of his functionalities without override a lot of things.

It’s quite easy to use, and this provide a great Developer experience with this process, even if you need to use some xml file.

Nevertheless this logic has a performance cost, code is called on nearly each method to see if there are any plugins connected.
A module exist to change this workflow, using static calls of chained plugins, depending on the area code at runtime: https://github.com/creatuity/magento2-interceptors.

The new setAreaCode method of interceptor class, now looks like this:

public function setAreaCode($code)
{
$arguments = func_get_args();
$result = $this->____plugin_plugin_alter_set_area_code()->aroundSetAreaCode($this, function(...$arguments){
$arguments = func_get_args();
return parent::setAreaCode(...array_values($arguments));
}, ...array_values($arguments));

return (($tmp = $this->____plugin_framework_state_newrelic()->afterSetAreaCode($this, $result, ...array_values($arguments))) !== null) ? $tmp : $result;
}

I hope Magento in this future versions, will adopt this workaround for better performance and utilize the power of attributes from PHP 8 to add more structured data and possibly reduce the use of generated metadata files.


Interceptor design pattern (AOP) implementation on Magento 2 was originally published in ekino-france on Medium, where people are continuing the conversation by highlighting and responding to this story.