before and after
A before plugin can replace the arguments going in; an after plugin can replace the result coming out. Together they cover most work.
public function beforeFoo($subject, $arg): ?array / public function afterFoo($subject, $result, $arg)Two of the three plugin types do almost everything. A before method runs ahead of the observed method and can change its arguments. An after method runs once the observed method has finished and can change its result.
The two signatures
| Type | Signature | Return |
|---|---|---|
| before | beforeFoo($subject, ...$args) | An array of replacement arguments, or null |
| after | afterFoo($subject, $result, ...$args) | The result, changed or not |
A before method that returns an array replaces the whole argument list, positionally. Returning null leaves the arguments alone, and a method with no return statement returns null: the two forms are the same thing. An after method is required to return something; if the observed method is void, $result is null.
An after method does not have to declare every argument of the observed method. Declare the ones you use, plus any that come before them in the signature.
What trips people up
Returning a short array from before. The array replaces every argument, so an omitted trailing argument falls back to its default rather than keeping the caller's value.
The second trap is types. Core methods are often untyped, and the value arriving may not be the type the name suggests.
Editing the body of a plugin method that already exists needs no cache work. The list of which before, after and around methods each plugin class declares is cached alongside the merged DI configuration, so adding or renaming a plugin method still needs bin/magento cache:clean config, even when di.xml is untouched.
Example
<?php
// app/code/Vendor/Checkout/Plugin/RelabelCompanyField.php
declare(strict_types=1);
namespace Vendor\Checkout\Plugin;
use Magento\Checkout\Block\Checkout\LayoutProcessor;
class RelabelCompanyField
{
/**
* Target: Magento\Checkout\Block\Checkout\LayoutProcessor::process()
*
* @param LayoutProcessor $subject
* @param array $result
* @return array
*/
public function afterProcess(LayoutProcessor $subject, array $result): array
{
$fieldset = &$result['components']['checkout']['children']['steps']['children']
['shipping-step']['children']['shippingAddress']['children']
['shipping-address-fieldset']['children'];
if (isset($fieldset['company'])) {
$fieldset['company']['label'] = __('Business name');
}
return $result;
}
}When to use it
- Force a default store id onto a service call that your own module makes everywhere.
- Strip a field from a checkout component array before it reaches the browser.
- Round or clamp the number a pricing service returns, without touching the service.
More examples
Changing an argument on the way in
The early return of null is the normal way to opt out: the arguments pass through untouched. The constructor is shown twice because promoted properties and the written-out form are the same code; Magento core uses the long form, newer modules use the short one.
<?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';
// Shorthand: a promoted constructor property.
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {
}
// Longhand: exactly the same class.
// private ScopeConfigInterface $scopeConfig;
//
// public function __construct(ScopeConfigInterface $scopeConfig)
// {
// $this->scopeConfig = $scopeConfig;
// }
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];
}
}The short-array trap
Keep this one in mind when a plugin on a method with optional arguments causes a change somewhere unrelated. The fix is to accept every argument and return every argument, in order, even the ones you do not touch.
<?php
// app/code/Vendor/Greeting/Plugin/DropsAnArgument.php
declare(strict_types=1);
namespace Vendor\Greeting\Plugin;
use Magento\Framework\Message\ManagerInterface;
class DropsAnArgument
{
public function beforeAddSuccessMessage(
ManagerInterface $subject,
$message,
$group = null
): array {
// Wrong: the returned array replaces the entire argument list,
// so $group is lost and falls back to its default of null.
// A message meant for a named group lands in the default group.
return ['Thanks. ' . $message];
}
}An after plugin on a method that takes arguments
The order is subject, result, then the original arguments. $amount is declared here only because the plugin might want it; an after method may stop at $result if it does not need anything else. Typing the parameters is safe in this case because the target class types them too.
<?php
// app/code/Vendor/Pricing/Plugin/ClampConfigValue.php
declare(strict_types=1);
namespace Vendor\Pricing\Plugin;
use Vendor\Pricing\Service\Rounder;
class ClampConfigValue
{
/**
* Target: Vendor\Pricing\Service\Rounder::round(float $amount): float
*
* @param Rounder $subject
* @param float $result
* @param float $amount
* @return float
*/
public function afterRound(Rounder $subject, float $result, float $amount): float
{
return $result < 0.0 ? 0.0 : $result;
}
}
// Output: round(-1.004) now returns 0.0 instead of -1.0
Discussion