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

Custom Payment Methods in Magento 2: Fundamentals and the Adapter Facade

Custom Payment Methods in Magento 2: Fundamentals and the Adapter Facade

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

Block 7 showed that Magento sometimes forces a core-mandated base class that is still no excuse for duplicated business logic - ContentTypeAbstract for Page Builder (chapter 59), Template for widgets (chapter 56). Block 8 runs into the exact same pattern twice: for payment methods and for shipping methods. This chapter starts with payment methods and lays the technical groundwork first, before chapter 63 builds the actual "redeem points" payment method.

The old way: AbstractMethod

Before Magento 2.2 - and in many older third-party extensions to this day - a payment method inherited directly from Magento\Payment\Model\Method\AbstractMethod. Method code, availability flags, and gateway logic all lived in the same class, often as public properties:

(for reference only, not a file in this module)
<?php

declare(strict_types=1);

namespace Vendor\Module\Model\Payment;

use Magento\Payment\Model\Method\AbstractMethod;
use Magento\Quote\Api\Data\CartInterface;

/**
 * Legacy-style payment method (pre-2.2 pattern), shown only for recognition -
 * NOT the pattern this chapter builds on for Mironsoft\Loyalty.
 */
class LegacyExampleMethod extends AbstractMethod
{
    /**
     * Method code, referenced by payment/vendor_module_legacy_example/* config paths.
     */
    protected $_code = 'vendor_module_legacy_example';

    /**
     * Offline method, no gateway communication.
     */
    protected $_isOffline = true;

    /**
     * Storefront checkout availability, historically a plain public property.
     */
    protected $_canUseCheckout = true;

    /**
     * Admin order creation availability.
     */
    protected $_canUseInternal = false;

    /**
     * Legacy availability hook - tightly bound to this very instance, hard to
     * unit test without instantiating the whole method object first.
     *
     * @param CartInterface|null $quote Current quote
     * @return bool
     */
    public function isAvailable(CartInterface $quote = null): bool
    {
        return parent::isAvailable($quote) && $quote !== null;
    }
}

Achtung: AbstractMethod still works - Magento never removed the class. The reason this chapter picks the more modern path anyway: an instance method like isAvailable() can only be tested together with the entire payment method instance, behavior and configuration are inseparably tangled, and every small adjustment (e.g. a new availability check) means a new subclass instead of a newly assembled building block.

The modern way: the Adapter facade

Since Magento 2.2, Magento\Payment\Model\Method\Adapter exists - a generic, ready-made implementation of MethodInterface that carries no business logic of its own, instead delegating it to swappable collaborators: a ValueHandlerPool for configuration values and availability, optionally a ValidatorPool and a CommandPool for actual gateway calls (authorization, capture - not needed for the purely discount-based "redeem points" method from chapter 63, since there's no real gateway behind it). A concrete payment method emerges almost entirely through a di.xml virtualType as a result - composition over inheritance, the same principle this project also uses to prefer plugins over preferences (see CLAUDE.md).

One real PHP artifact is still needed: a class that holds the method code as a constant and - since the payment method needs to react to customer data - supplies checkout configuration to the frontend:

app/code/Mironsoft/Loyalty/Model/Payment/PointsRedemptionConfigProvider.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Payment;

use Magento\Checkout\Model\ConfigProviderInterface;
use Magento\Customer\Model\Session as CustomerSession;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;

/**
 * Exposes the loyalty payment method's code and the current customer's points
 * balance to the checkout JavaScript layer via window.checkoutConfig.
 */
class PointsRedemptionConfigProvider implements ConfigProviderInterface
{
    /**
     * Payment method code. Single source of truth, referenced from di.xml (as a
     * const argument) and read back out of window.checkoutConfig by the checkout
     * JS component in chapter 65 - both sides always agree on the exact string.
     */
    public const string METHOD_CODE = 'mironsoft_loyalty_points';

    /**
     * @param CustomerSession $customerSession Frontend customer session
     * @param LoyaltyConfig $loyaltyConfig Loyalty module configuration reader
     */
    public function __construct(
        private readonly CustomerSession $customerSession,
        private readonly LoyaltyConfig $loyaltyConfig,
    ) {
    }

    /**
     * Builds the configuration array merged into window.checkoutConfig.
     *
     * @return array<string, mixed>
     */
    public function getConfig(): array
    {
        $balance = $this->customerSession->isLoggedIn()
            ? (int) $this->customerSession->getCustomer()->getData('loyalty_points_balance')
            : 0;

        return [
            'mironsoftLoyalty' => [
                'methodCode' => self::METHOD_CODE,
                'pointsBalance' => $balance,
                'pointsPerEuro' => $this->loyaltyConfig->getPointsPerEuro(),
            ],
        ];
    }
}

payment.xml and registration via config.xml

etc/payment.xml is the declarative interface for method-wide properties that aren't ordinary configuration values - here only allow_multiple_address, which controls whether the method is offered at all on a multi-address order (deliberately 0 for points-based payment, see chapter 70 for the reasoning):

app/code/Mironsoft/Loyalty/etc/payment.xml
<?xml version="1.0"?>
<payment xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Payment:etc/payment.xsd">
    <methods>
        <method name="mironsoft_loyalty_points">
            <allow_multiple_address>0</allow_multiple_address>
        </method>
    </methods>
</payment>

Which class Magento actually instantiates for the method code mironsoft_loyalty_points, however, isn't declared in payment.xml - just like shipping carriers later in chapter 66, it's a model configuration value in etc/config.xml:

app/code/Mironsoft/Loyalty/etc/config.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
    <default>
        <payment>
            <mironsoft_loyalty_points>
                <active>0</active>
                <model>Mironsoft\Loyalty\Model\Payment\PointsRedemptionFacade</model>
                <title>Mit Treuepunkten bezahlt</title>
                <can_use_checkout>1</can_use_checkout>
                <can_use_internal>0</can_use_internal>
                <sort_order>5</sort_order>
            </mironsoft_loyalty_points>
        </payment>
    </default>
</config>

And the Adapter facade itself, together with the ValueHandlerPool and the config provider registration:

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">

    <virtualType name="Mironsoft\Loyalty\Model\Payment\ValueHandlerPool"
                 type="Magento\Payment\Gateway\Config\ValueHandlerPool">
        <arguments>
            <argument name="handlers" xsi:type="array">
                <item name="default" xsi:type="string">Magento\Payment\Gateway\Config\ConfigValueHandler</item>
                <!-- the "availability" entry is added in chapter 64 -->
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="Mironsoft\Loyalty\Model\Payment\PointsRedemptionFacade"
                 type="Magento\Payment\Model\Method\Adapter">
        <arguments>
            <argument name="code" xsi:type="const">Mironsoft\Loyalty\Model\Payment\PointsRedemptionConfigProvider::METHOD_CODE</argument>
            <argument name="formBlockType" xsi:type="string">Magento\Payment\Block\Form</argument>
            <argument name="infoBlockType" xsi:type="string">Magento\Payment\Block\Info</argument>
            <argument name="valueHandlerPool" xsi:type="object">Mironsoft\Loyalty\Model\Payment\ValueHandlerPool</argument>
        </arguments>
    </virtualType>

    <type name="Magento\Checkout\Model\CompositeConfigProvider">
        <arguments>
            <argument name="configProviders" xsi:type="array">
                <item name="mironsoft_loyalty_points" xsi:type="object">Mironsoft\Loyalty\Model\Payment\PointsRedemptionConfigProvider</item>
            </argument>
        </arguments>
    </type>
</config>

Tipp: can_use_checkout and can_use_internal in config.xml are already the modern answer to AbstractMethod's $_canUseCheckout/$_canUseInternal from above: the Adapter delegates every can* capability check to the default handler of the ValueHandlerPool, which simply reads the matching payment/<code>/<field> configuration value. Chapter 64 goes deeper.

That registers the payment method technically, but it's still non-functional - it doesn't appear in checkout (disabled) and doesn't book any points yet. Chapter 63 fills exactly that gap, and first settles the most important design question: how does "partial payment with points" even fit into a system that only ever knows one payment method per order?