Create a custom payment method on Magento 2

Payment methods play a central role in any e-commerce project. Today, many solutions already exist on the market, such as HiPay or Lyra, and cover the majority of standard needs.

However, some projects require more specific behaviors:

  • on-site payment
  • deferred payment
  • cash on delivery

In situations like this, existing modules quickly reach their limits, and creating a custom payment method then becomes the best solution.

The goal of this article is to understand how Magento structures its payment system and how to develop a simple, clean, and extensible payment method.

We will intentionally start from a minimal architecture in order to clearly understand the role of each file.

1. Module structure

As seen in a previous article, a Magento 2 module is based on a standard structure.

Here is the final architecture of our payment method:

app/code/My/Module/
├── registration.php
├── etc/
│   ├── adminhtml/
│   │   └── system.xml
│   ├── config.xml
│   ├── module.xml
│   └── payment.xml
├── Model/
│   └── Payment/
│       └── CustomPayment.php
└── view/
    └── frontend/
        ├── layout/
        │   └── checkout_index_index.xml
        └── web/
            ├── js/
            │   └── view/
            │       └── payment/
            │           ├── method-renderer/
            │           │   └── custom-payment-method.js
            │           └── custom-payment.js
            └── template/
                └── payment/
                    └── custom-payment.html

Although this structure may seem extensive at first glance, each file has a very specific role.

We will now go through each of these elements in detail.

2. Role of the module files

system.xml — back-office configuration

This file is used to expose the payment configuration in the Magento administration panel.

<system>
    <section id="payment">
        <group id="custom_payment" translate="label" type="text" sortOrder="1" showInDefault="1" showInWebsite="1" showInStore="1">
            <label>Custom Payment</label>
            <field id="active" translate="label" type="select" sortOrder="1" showInDefault="1" showInWebsite="1" canRestore="1">
                <label>Enabled</label>
                <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
            </field>
            <field id="order_status" translate="label" type="select" sortOrder="20" showInDefault="1" showInWebsite="1" canRestore="1">
                <label>New Order Status</label>
                    <source_model>Magento\Sales\Model\Config\Source\Order\Status\NewStatus</source_model>
            </field>
            <field id="sort_order" translate="label" type="text" sortOrder="100" showInDefault="1" showInWebsite="1">
                <label>Sort Order</label>
                <frontend_class>validate-number</frontend_class>
            </field>
            <field id="title" translate="label" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1">
                <label>Title</label>
            </field>
            <field id="allowspecific" translate="label" type="allowspecific" sortOrder="50" showInDefault="1" showInWebsite="1" canRestore="1">
                <label>Payment from Applicable Countries</label>
                    <source_model>Magento\Payment\Model\Config\Source\Allspecificcountries</source_model>
            </field>
            <field id="specificcountry" translate="label" type="multiselect" sortOrder="51" showInDefault="1" showInWebsite="1">
                <label>Payment from Specific Countries</label>
                <source_model>Magento\Directory\Model\Config\Source\Country</source_model>
                <can_be_empty>1</can_be_empty>
            </field>
            <field id="min_order_total" translate="label" type="text" sortOrder="98" showInDefault="1" showInWebsite="1">
                <label>Minimum Order Total</label>
                <validate>validate-number validate-zero-or-greater</validate>
            </field>
            <field id="max_order_total" translate="label" type="text" sortOrder="99" showInDefault="1" showInWebsite="1">
                <label>Maximum Order Total</label>
                <validate>validate-number validate-zero-or-greater</validate>
            </field>
            <field id="model"></field>
        </group>
    </section>
</system>

Main fields

  • active : enables or disables the payment method
  • order_status : order status after payment
  • sort_order : display order in checkout
  • title : label shown to the customer
  • allowspecific : label shown to the customer
  • specificcountry : list of allowed countries
  • min_order_total : minimum order amount
  • max_order_total : maximum order amount

config.xml — default values

This file defines the initial values of the payment method:

<default>
<payment>
<sample_gateway>
<active>0</active>
<model>My\Module\Model\Payment\CustomPayment</model>
<order_status>pending</order_status>
<title>Custom Payment</title>
<can_use_checkout>1</can_use_checkout>
<group>offline</group>
</sample_gateway>
</payment>
</default>

Main fields

  • can_use_checkout : visibility in checkout
  • group : payment category (offline / online)
  • model : associated PHP class for the payment method

payment.xml — payment method declaration

This file allows you to add specific behaviors to a payment method:

<payment>
<methods>
<method name="custom_payment">
<allow_multiple_address>1</allow_multiple_address>
</method>
</methods>
</payment>

Role of allow_multiple_address

It enables the payment method to be used with multi-address orders, where each item in the order can be shipped to a different address.

CustomPayment.php — backend logic

This class, declared in etc/config.xml, represents the backend logic of the payment method.
It extends Magento’s native payment engine and allows the payment behavior to be centralized.

In our case, we mainly define the unique payment method code here so that it can be easily reused throughout the entire module.

use Magento\Payment\Model\Method\Adapter as PaymentAdapter;

class CustomPayment extends PaymentAdapter
{
    public const CODE = 'custom_payment';
}

Role

  • defines the unique payment method code
  • centralizes backend logic
  • allows extending:
    • authorize()
    • capture()
    • refund()
    • isAvailable()

3. Integration into the checkout

Magento 2 uses a fully component-based checkout built on:

  • UI Components,
  • KnockoutJS,
  • RequireJS.

The payment method must therefore be registered on the frontend side.

checkout_index_index.xml

This file injects the payment method into the Magento checkout via jsLayout.

<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">
                                                    <!-- merge payment method renders here -->
                                                    <item name="children" xsi:type="array">
                                                        <item name="custom-payments" xsi:type="array">
                                                            <item name="component" xsi:type="string">My_Module/js/view/payment/custom-payment</item>
                                                            <item name="methods" xsi:type="array">
                                                                <item name="custom_payment" 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>

It allows:

  • registering the JS component
  • defining payment options
  • controlling certain behaviors (e.g., required address)

custom-payment.js — payment registration

This file adds the payment method to the list of available payment options:

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

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

    /** Add view logic here if needed */
    return Component.extend({});
});

Magento uses a global list called rendererList.

It contains all payment methods available in the checkout.

By adding our method to this list, we tell Magento:

  • the type: the unique payment identifier, which must be consistent everywhere and determines which payment method to display
  • the component: which KnockoutJS component to use to render it

custom-payment-method.js — main component

This component represents the frontend logic of the payment method.

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

    return Component.extend({
        defaults: {
            template: 'My_Module/payment/custom-payment'
        },
    });
});

Role

  • extends Magento’s payment system
  • defines the template used
  • can include frontend business logic

custom-payment.html — frontend display

This is the KnockoutJS template displayed in the checkout.

<div class="payment-method" data-bind="css: {'_active': (getCode() == isChecked())}">
    <div class="payment-method-title field choice">
        <input type="radio"
               name="payment[method]"
               class="radio"
               data-bind="attr: {'id': getCode()}, value: getCode(), checked: isChecked, click: selectPaymentMethod, visible: isRadioButtonVisible()"/>
        <label data-bind="attr: {'for': getCode()}" class="label"><span data-bind="text: getTitle()"></span></label>
    </div>

    <div class="payment-method-content">
        <!-- ko foreach: getRegion('messages') -->
        <!-- ko template: getTemplate() --><!-- /ko -->
        <!--/ko-->
        <div class="payment-method-billing-address">
            <!-- ko foreach: $parent.getRegion(getBillingAddressFormName()) -->
            <!-- ko template: getTemplate() --><!-- /ko -->
            <!--/ko-->
        </div>
        <p data-bind="html: getInstructions()"></p>
        <div class="checkout-agreements-block">
            <!-- ko foreach: $parent.getRegion('before-place-order') -->
            <!-- ko template: getTemplate() --><!-- /ko -->
            <!--/ko-->
        </div>
        <div class="actions-toolbar">
            <div class="primary">
                <button class="action primary checkout"
                        type="submit"
                        data-bind="
                        click: placeOrder,
                        attr: {title: $t('Place Order')},
                        enable: (getCode() == isChecked()),
                        css: {disabled: !isPlaceOrderActionAllowed()}
                        "
                        disabled>
                    <span data-bind="i18n: 'Place Order'"></span>
                </button>
            </div>
        </div>

    </div>
</div>

What it handles:

  • selecting the payment method
  • displaying the title
  • billing address section
  • “Place Order” button
  • checkout validation