← Back to blog

Magento 2 Custom Payment Method with the Payment Gateway API

Rodrigo Biazzi

There are two ways to write a payment method in Magento 2. The old one extends Magento\Payment\Model\Method\AbstractMethod, which has been deprecated for years but still appears in many tutorials and extensions. The current one is the Payment Gateway API, where the method is a configured adapter and the real work happens in small classes wired together in di.xml.

This article uses the Payment Gateway API on Magento Open Source and Adobe Commerce 2.4.8. It starts with an offline method, similar to Check / Money Order, then adds an authorize command that calls an external gateway. The same structure scales from a simple "pay on invoice" method to a full card integration.

The pieces of a gateway payment method

Before the code, a map of what each part does:

  • Facade. A virtual type of Magento\Payment\Model\Method\Adapter. This is the object Magento sees as the payment method.
  • Value handler pool. Answers config questions like "is this method active" or "can it capture", usually by reading payment/<code>/* config.
  • Validator pool. Optional checks, for example allowed countries or currencies.
  • Command pool. Maps operations like authorize, capture, void and refund to command objects.
  • Gateway command. For each operation: a request builder, a transfer factory, an HTTP client, a response validator and a response handler.
  • Frontend renderer. A JavaScript component and a Knockout template that show the method in the Luma checkout.

It looks like a lot of classes for a payment method. The benefit is that each piece is small, testable and replaceable through DI, and that is how the core Braintree and PayPal integrations are written.

Configuration: config.xml

Payment methods live under payment/<code>. The model node points to the facade virtual type, not to a PHP class.

<?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>
            <vendorpay>
                <active>0</active>
                <model>VendorPayFacade</model>
                <title>Pay by Bank Transfer</title>
                <order_status>pending</order_status>
                <payment_action>authorize</payment_action>
                <can_authorize>1</can_authorize>
                <can_use_checkout>1</can_use_checkout>
                <can_use_internal>1</can_use_internal>
                <is_gateway>1</is_gateway>
                <allowspecific>0</allowspecific>
                <sort_order>50</sort_order>
            </vendorpay>
        </payment>
    </default>
</config>

The can_* flags are read through the value handler pool. If can_capture is missing, the admin will not offer online capture on invoices. If can_use_internal is off, the method does not appear when creating orders in the admin. Add a matching group to etc/adminhtml/system.xml under the payment section so merchants can change title, status and countries.

Wiring it in 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="VendorPayFacade" type="Magento\Payment\Model\Method\Adapter">
        <arguments>
            <argument name="code" xsi:type="const">Vendor\Payment\Model\Ui\ConfigProvider::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">VendorPayValueHandlerPool</argument>
            <argument name="commandPool" xsi:type="object">VendorPayCommandPool</argument>
        </arguments>
    </virtualType>

    <virtualType name="VendorPayConfig" type="Magento\Payment\Gateway\Config\Config">
        <arguments>
            <argument name="methodCode" xsi:type="const">Vendor\Payment\Model\Ui\ConfigProvider::CODE</argument>
        </arguments>
    </virtualType>

    <virtualType name="VendorPayConfigValueHandler" type="Magento\Payment\Gateway\Config\ConfigValueHandler">
        <arguments>
            <argument name="configInterface" xsi:type="object">VendorPayConfig</argument>
        </arguments>
    </virtualType>

    <virtualType name="VendorPayValueHandlerPool" type="Magento\Payment\Gateway\Config\ValueHandlerPool">
        <arguments>
            <argument name="handlers" xsi:type="array">
                <item name="default" xsi:type="string">VendorPayConfigValueHandler</item>
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="VendorPayCommandPool" type="Magento\Payment\Gateway\Command\CommandPool">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="authorize" xsi:type="string">VendorPayAuthorizeCommand</item>
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="VendorPayAuthorizeCommand" type="Magento\Payment\Gateway\Command\GatewayCommand">
        <arguments>
            <argument name="requestBuilder" xsi:type="object">Vendor\Payment\Gateway\Request\AuthorizeRequest</argument>
            <argument name="transferFactory" xsi:type="object">Vendor\Payment\Gateway\Http\TransferFactory</argument>
            <argument name="client" xsi:type="object">Vendor\Payment\Gateway\Http\Client</argument>
            <argument name="handler" xsi:type="object">Vendor\Payment\Gateway\Response\TransactionHandler</argument>
            <argument name="validator" xsi:type="object">Vendor\Payment\Gateway\Validator\ResponseValidator</argument>
        </arguments>
    </virtualType>
</config>

For a purely offline method you can drop the command pool completely and set payment_action to nothing. The order is placed and stays in the configured status until someone invoices it by hand. The rest of this article assumes you want the authorize call.

If virtual types are new to you, dependency injection and virtual types explains how this file is read.

The request builder

A builder receives a "subject" array and returns an array that will become the request body. Use Magento\Payment\Gateway\Helper\SubjectReader to read it instead of indexing the array yourself.

<?php
declare(strict_types=1);

namespace Vendor\Payment\Gateway\Request;

use Magento\Payment\Gateway\Helper\SubjectReader;
use Magento\Payment\Gateway\Request\BuilderInterface;

class AuthorizeRequest implements BuilderInterface
{
    public function build(array $buildSubject): array
    {
        $paymentDO = SubjectReader::readPayment($buildSubject);
        $amount = SubjectReader::readAmount($buildSubject);
        $order = $paymentDO->getOrder();

        return [
            'reference' => $order->getOrderIncrementId(),
            'amount' => number_format((float) $amount, 2, '.', ''),
            'currency' => $order->getCurrencyCode(),
            'email' => $order->getBillingAddress()?->getEmail(),
        ];
    }
}

$paymentDO->getOrder() returns an OrderAdapterInterface, a thin read only wrapper. You can also call $paymentDO->getPayment() to reach the payment info instance and its additional information. When the request has many parts, split them into several builders and combine them with Magento\Payment\Gateway\Request\BuilderComposite.

Transfer, client, validator, handler

The transfer factory turns the array into a TransferInterface object using Magento\Payment\Gateway\Http\TransferBuilder, where you set the body, headers, URI and method. The client implements Magento\Payment\Gateway\Http\ClientInterface with a single placeRequest(TransferInterface $transferObject) method that sends the request and returns the decoded response as an array. Keep logging in the client, using Magento\Payment\Model\Method\Logger, which masks values you mark as sensitive.

The validator checks the response and returns a result built by Magento\Payment\Gateway\Validator\ResultInterfaceFactory. If it fails, the command throws and the order is not placed. The customer sees a generic error, so log the real reason.

The handler stores what came back on the payment:

<?php
declare(strict_types=1);

namespace Vendor\Payment\Gateway\Response;

use Magento\Payment\Gateway\Helper\SubjectReader;
use Magento\Payment\Gateway\Response\HandlerInterface;
use Magento\Sales\Model\Order\Payment;

class TransactionHandler implements HandlerInterface
{
    public function handle(array $handlingSubject, array $response): void
    {
        $paymentDO = SubjectReader::readPayment($handlingSubject);
        $payment = $paymentDO->getPayment();

        if ($payment instanceof Payment) {
            $payment->setTransactionId((string) $response['transaction_id']);
            $payment->setIsTransactionClosed(false);
            $payment->setAdditionalInformation('gateway_status', (string) $response['status']);
        }
    }
}

Showing the method in checkout

The backend part makes the method available to the API. The Luma checkout still needs a renderer. Register it in view/frontend/layout/checkout_index_index.xml:

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="checkout.root">
            <arguments>
                <argument name="jsLayout" xsi:type="array">
                    <item name="components" xsi:type="array">
                        <item name="checkout" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="steps" xsi:type="array">
                                    <item name="children" xsi:type="array">
                                        <item name="billing-step" xsi:type="array">
                                            <item name="children" xsi:type="array">
                                                <item name="payment" xsi:type="array">
                                                    <item name="children" xsi:type="array">
                                                        <item name="renders" xsi:type="array">
                                                            <item name="children" xsi:type="array">
                                                                <item name="vendorpay" xsi:type="array">
                                                                    <item name="component" xsi:type="string">Vendor_Payment/js/view/payment/vendorpay</item>
                                                                    <item name="methods" xsi:type="array">
                                                                        <item name="vendorpay" xsi:type="array">
                                                                            <item name="isBillingAddressRequired" xsi:type="boolean">true</item>
                                                                        </item>
                                                                    </item>
                                                                </item>
                                                            </item>
                                                        </item>
                                                    </item>
                                                </item>
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

The registered component adds the renderer to the list:

define([
    'uiComponent',
    'Magento_Checkout/js/model/payment/renderer-list'
], function (Component, rendererList) {
    'use strict';

    rendererList.push({
        type: 'vendorpay',
        component: 'Vendor_Payment/js/view/payment/method-renderer/vendorpay'
    });

    return Component.extend({});
});

And the renderer extends the default one, which already knows how to select the method, show the billing address and place the order:

define([
    'Magento_Checkout/js/view/payment/default'
], function (Component) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Vendor_Payment/payment/vendorpay'
        }
    });
});

For the template in view/frontend/web/template/payment/vendorpay.html, copy the one from Magento_OfflinePayments for Check / Money Order and adapt it. It contains the radio input, the billing address region, the agreements and the place order button with the right bindings. Writing that template from memory is a good way to lose the terms and conditions block.

If the renderer needs data from the server, such as a public key or an instructions text, implement Magento\Checkout\Model\ConfigProviderInterface and add your class to the configProviders argument of Magento\Checkout\Model\CompositeConfigProvider in etc/frontend/di.xml. The data appears in window.checkoutConfig.payment.

Hyvä Checkout and headless storefronts do not use this JavaScript. With GraphQL, the method shows up in available_payment_methods as soon as the backend says it is available, and the frontend sends its code in setPaymentMethodOnCart. See GraphQL cart and checkout mutations.

Capture, void and refund

Each extra operation is one more entry in the command pool and one more GatewayCommand virtual type. The builders change, but the transfer factory and the client are usually shared. Then switch on the matching flags in config.xml: can_capture, can_void, can_refund and, if the gateway supports it, can_capture_partial and can_refund_partial_per_invoice. Without the flags the admin buttons do not appear, even when the commands exist.

The capture builder needs the transaction id from the authorization. Read it from the payment with getParentTransactionId() or from the additional information you saved in the handler. Keep the amounts as the gateway expects them, usually in the order currency, and be careful with multi currency stores where base and order currency differ.

Test every operation against the gateway sandbox, and write unit tests for builders and handlers. They are plain classes with array input and array output, which makes them some of the easiest code in Magento to test. Unit tests with PHPUnit covers the setup.

When the method does not appear

Magento runs a list of checks from Magento\Payment\Model\Checks before showing a method. In practice, look at these in order:

  1. Is the method active in the website scope of the store you are testing?
  2. Is can_use_checkout set to 1?
  3. Do the allowed countries include the billing country?
  4. Do minimum and maximum order total settings, if you added them, exclude the cart?
  5. Is the order total zero? Then only the Zero Subtotal Checkout method is offered.
  6. Is the JS renderer loaded? Check the browser console for a RequireJS error on your component path.

FAQ

Should I still extend AbstractMethod for a new payment method?

No. AbstractMethod is deprecated. Use the Payment Gateway API with Magento\Payment\Model\Method\Adapter as the facade. Old extensions still work, but new code should follow the gateway structure.

Do I need a command pool for an offline payment method?

No. A method that only records the order, like bank transfer, can work with just the facade and the value handler pool. Commands are needed when you call an external gateway.

How do I pass extra data from checkout to the payment?

Override getData in the JS renderer to add fields under additional_data. Then copy them to the payment additional information in an observer that extends Magento\Payment\Observer\AbstractDataAssignObserver, listening to the payment_method_assign_data_vendorpay event.

Why is my payment method missing in the admin order form?

Check that can_use_internal is set to 1 for the method. The admin order form uses that flag instead of can_use_checkout.

I am available for freelance Magento and Adobe Commerce work, including payment gateway integrations. If you need help with this, get in touch.

Related articles