Plugins That Age Well
Target interfaces, keep the plugin a thin shim over your own service, and never build on a core method that was never meant to be public API.
<type name="Vendor\Module\Api\ThingInterface"><plugin name="..." type="..."/></type>A plugin is a long-lived dependency on someone else's method. These habits keep it working across minor upgrades.
Target the interface
Plugins are inherited: an implementation of an interface, or a subclass, also gets the plugins declared on what it implements or extends. Declaring on Vendor\Module\Api\ThingInterface therefore keeps working when the concrete class behind it is replaced by a preference, the DI setting that says which class answers for an interface. The trade is reach: an interface plugin fires for every implementation, so check how many there are before choosing.
Keep it thin
A plugin method should decide almost nothing. Put the logic in a class of your own and have the plugin call it. The plugin then has one job, the logic can be unit tested without interception, and the day the target method changes you rewrite four lines rather than forty.
Do not grow new behaviour on core internals
Private and protected methods cannot be intercepted at all, and that is a warning as much as a limit: they are not API, and they change in patch releases. Methods marked @api are the ones Magento commits to keeping stable. Prefer those, and when you plug into something else, expect to revisit it at the next upgrade.
Finally, return what the target promised. An after plugin that returns a different type breaks every caller, including ones in other modules you have not read. Each change is applied with bin/magento cache:clean config.
Example
<?php
// app/code/Vendor/Greeting/Plugin/StampSuccessMessage.php
declare(strict_types=1);
namespace Vendor\Greeting\Plugin;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Message\ManagerInterface;
class StampSuccessMessage
{
private const XML_PATH_SUFFIX = 'vendor_greeting/message/suffix';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {
}
/**
* Target: Magento\Framework\Message\ManagerInterface::addSuccessMessage()
*
* @param ManagerInterface $subject
* @param string|\Magento\Framework\Phrase $message
* @param string|null $group
* @return array|null
*/
public function beforeAddSuccessMessage(
ManagerInterface $subject,
$message,
$group = null
): ?array {
$suffix = (string)$this->scopeConfig->getValue(self::XML_PATH_SUFFIX);
if ($suffix === '') {
return null;
}
return [$message . ' ' . $suffix, $group];
}
}When to use it
- Attach a plugin to a service contract so a later preference does not quietly disable it.
- Refactor a large plugin into a thin interception shim plus a testable service.
- Review a third-party module and judge how likely its plugins are to break at the next upgrade.
More examples
Interface or concrete class
Declare one of these, not both. Use the interface when you mean the behaviour, and the concrete class when you mean that one implementation and want the other implementations left alone. The file sits in etc/frontend/ so the admin is unaffected.
<!-- app/code/Vendor/Greeting/etc/frontend/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Preferred: survives a preference that swaps the implementation -->
<type name="Magento\Framework\Message\ManagerInterface">
<plugin name="vendor_greeting_stamp_success_message"
type="Vendor\Greeting\Plugin\StampSuccessMessage"/>
</type>
<!-- Narrower: only this class, and only if nothing replaces it -->
<type name="Magento\Framework\Message\Manager">
<plugin name="vendor_greeting_stamp_success_message"
type="Vendor\Greeting\Plugin\StampSuccessMessage"/>
</type>
</config>The plugin as a shim over your own service
MessageSuffix is a plain class in the same module, so the rule about what to append can be unit tested on its own and reused by a controller or a console command. The Phrase check is here because core passes translated Phrase objects where the parameter name says string.
<?php
// app/code/Vendor/Greeting/Plugin/StampSuccessMessage.php
declare(strict_types=1);
namespace Vendor\Greeting\Plugin;
// Shorthand: a group use declaration.
use Magento\Framework\{Message\ManagerInterface, Phrase};
// Longhand: the same two imports written out.
// use Magento\Framework\Message\ManagerInterface;
// use Magento\Framework\Phrase;
use Vendor\Greeting\Service\MessageSuffix;
class StampSuccessMessage
{
public function __construct(
private readonly MessageSuffix $suffix
) {
}
public function beforeAddSuccessMessage(
ManagerInterface $subject,
$message,
$group = null
): ?array {
if ($message instanceof Phrase) {
$message = (string)$message;
}
return $this->suffix->isConfigured()
? [$this->suffix->append($message), $group]
: null;
}
}Keeping the contract the caller expects
Use this as a template when you are only observing. Read the target's @return annotation before you write the plugin, and return that type unchanged unless changing it is the point of the plugin.
<?php
// app/code/Vendor/Greeting/Plugin/CountMessages.php
declare(strict_types=1);
namespace Vendor\Greeting\Plugin;
use Magento\Framework\Message\ManagerInterface;
class CountMessages
{
/**
* Target: Magento\Framework\Message\ManagerInterface::getMessages()
*
* $result is a Magento\Framework\Message\Collection. Returning an array of
* messages here would break every caller that calls a collection method on it.
*
* @param ManagerInterface $subject
* @param \Magento\Framework\Message\Collection $result
* @return \Magento\Framework\Message\Collection
*/
public function afterGetMessages(ManagerInterface $subject, $result)
{
return $result;
}
}
Discussion