Magento 2 Custom Payment Method: Complete Dev Guide

Magento 2 Custom Payment Method: Complete Dev Guide

Magento 2 Custom Payment Method: Complete Dev Guide

Building a Magento 2 custom payment method is complex. Many moving parts exist — and most tutorials skip them. Request Builders, Response Handlers, Gateway Command Pools, validators, and UI components each have a specific role. When one breaks, the failure is usually silent or cryptically logged.

This guide dissects the full Gateway Command Pipeline from the inside out. You'll understand why each piece exists, not just where to paste it.

New to Magento's DI container? Review the official dependency injection documentation before continuing. Several patterns below rely on virtual types and constructor injection.

---

What You'll Need

- Magento 2.4.6+ running on PHP 8.2+ - A working local development environment (Warden, DDEV, or native) - Familiarity with Magento module structure, DI, and XML configuration - Basic understanding of payment gateway request/response cycles - Composer and CLI access

---

Key Terms

| Term | Definition | |---|---| | Gateway Command Pipeline | The structured chain of classes Magento uses to process each payment action (authorize, capture, void, refund) | | Facade | The \Magento\Payment\Model\Method\Adapter class that ties all pipeline components together via di.xml | | RequestBuilder | Assembles the data array sent to the external gateway | | TransferFactory | Wraps the assembled request for HTTP transport | | ResponseValidator | Checks the gateway response for success or failure | | Tokenization | Replacing sensitive card data with an opaque token generated client-side | | PCI-DSS | Payment Card Industry Data Security Standard — compliance rules governing how card data is handled |

---

Understanding Magento 2 Payment Method Architecture

Magento 2.1 introduced the Gateway Command Pipeline. It matured through 2.4.x releases. This approach replaced the older Mage_Payment model from Magento 1. It also replaced the deprecated \Magento\Payment\Model\Method\AbstractMethod pattern. Do not use either legacy approach.

Before diving into the pipeline steps, here is a quick mental model: each payment action flows through a chain of small, focused classes. No single class owns the entire transaction. That separation is intentional, and it matters for compliance.

Here is how the pipeline works:

Initiation and routing. Checkout initiates payment. The PaymentMethod Facade routes to the CommandPool. The Command selects the appropriate RequestBuilders.

Transport. RequestBuilders assemble a data array. The TransferFactory wraps that data into an HTTP transfer object. The HttpClient sends it to the gateway.

Response handling. The ResponseValidator checks the gateway response. The ResponseHandlers write results back into Magento's order and payment objects.

Request Phase

Each Command — Authorize, Capture, Void, Refund — is a discrete unit of work. RequestBuilders assemble a data array. Use a BuilderComposite to combine multiple focused builders. Keep each builder responsible for one concern.

In short: the Request Phase collects and structures outbound data. Nothing more.

Transport Phase

The TransferFactory wraps the assembled data into an HTTP transfer object. The HttpClient sends it. Keep these classes thin. They must not contain business logic.

In short: the Transport Phase moves data. It does not transform or validate it.

Response Phase

The ResponseValidator checks for success codes. The ResponseHandler writes results back into Magento's order and payment objects. Keep each handler small and single-purpose.

In short: the Response Phase updates Magento state based on what the gateway returned.

You configure everything through di.xml. You do not need a custom PHP class for the Facade in most cases. The Facade class is \Magento\Payment\Model\Method\Adapter — it ties everything together through virtual type configuration.

PCI-DSS reminder: The pipeline separates concerns intentionally. Raw card data should never reach your application layer. Your RequestBuilder assembles token references, not PANs. Your ResponseHandler stores transaction IDs, not plaintext auth codes.

You now have a mental model of how the pipeline flows. Next, we will create the actual module structure.

---

Setting Up the Payment Module Structure

Start with the standard module scaffold. This guide uses Unomage_PaymentGateway.

Create the directory structure:

mkdir -p app/code/Unomage/PaymentGateway/{etc/adminhtml,Gateway/{Command,Request,Response,Validator},Model,view/frontend/web/js/view/payment}

Next, add the module declaration. Create etc/module.xml:

<?xml version="1.0"?>

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> <module name="Unomage_PaymentGateway" setup_version="1.0.0"> <sequence> <module name="Magento_Payment"/> <module name="Magento_Sales"/> </sequence> </module> </config>

The module.xml file declares dependencies on Magento_Payment and Magento_Sales. This ensures the core payment framework loads before your module initializes.

Then define your payment method defaults in 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> <unomage_gateway> <active>1</active> <title>Unomage Payment Gateway</title> <payment_action>authorize</payment_action> <order_status>pending_payment</order_status> <currency>USD</currency> <can_authorize>1</can_authorize> <can_capture>1</can_capture> <can_void>1</can_void> <can_refund>1</can_refund> <model>UnomagePaymentGatewayFacade</model> </unomage_gateway> </payment> </default> </config>

Use backend_model="Magento\Config\Model\Config\Backend\Encrypted" for API key fields in etc/adminhtml/system.xml. This is required for PCI scope:

<field id="api_key" translate="label" type="obscure" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="0">

<label>API Key</label> <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model> </field>

Security: Never store API credentials as plain text in core_config_data. The Encrypted backend model uses Magento's deployment-specific encryption key. To rotate keys or migrate environments, use bin/magento encryption:payment-data:update (introduced in 2.4.4).

---

Implementing the Gateway Commands

This is where most payment module tutorials fall apart. Let's go deep.

Facade and Command Pool

<virtualType name="UnomagePaymentGatewayFacade" type="Magento\Payment\Model\Method\Adapter">

<arguments> <argument name="code" xsi:type="const">Unomage\PaymentGateway\Model\Ui\ConfigProvider::CODE</argument> <argument name="formBlockType" xsi:type="string">Magento\Payment\Block\Form</argument> <argument name="infoBlockType" xsi:type="string">Unomage\PaymentGateway\Block\Info</argument> <argument name="valueHandlerPool" xsi:type="object">UnomagePaymentGatewayValueHandlerPool</argument> <argument name="commandPool" xsi:type="object">UnomagePaymentGatewayCommandPool</argument> </arguments> </virtualType>

<virtualType name="UnomagePaymentGatewayCommandPool" type="Magento\Payment\Gateway\Command\CommandPool"> <arguments> <argument name="commands" xsi:type="array"> <item name="authorize" xsi:type="string">UnomagePaymentGatewayAuthorizeCommand</item> <item name="capture" xsi:type="string">UnomagePaymentGatewayCaptureCommand</item> <item name="void" xsi:type="string">UnomagePaymentGatewayVoidCommand</item> </argument> </arguments> </virtualType>

The Facade virtual type wires your CommandPool to the Adapter base class. The CommandPool maps action names to their concrete command virtual types.

The Authorize Command

Each command chains builders, a transfer factory, a client, validators, and handlers:

<virtualType name="UnomagePaymentGatewayAuthorizeCommand" type="Magento\Payment\Gateway\Command\GatewayCommand">

<arguments> <argument name="requestBuilder" xsi:type="object">UnomagePaymentGatewayAuthorizeRequest</argument> <argument name="transferFactory" xsi:type="object">Unomage\PaymentGateway\Gateway\Http\TransferFactory</argument> <argument name="client" xsi:type="object">Unomage\PaymentGateway\Gateway\Http\Client\AuthorizeClient</argument> <argument name="validator" xsi:type="object">Unomage\PaymentGateway\Gateway\Validator\ResponseCodeValidator</argument> <argument name="handler" xsi:type="object">UnomagePaymentGatewayAuthorizeHandler</argument> </arguments> </virtualType>

Request Builder

<?php

declare(strict_types=1);

namespace Unomage\PaymentGateway\Gateway\Request;

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

class AuthorizeRequestBuilder implements BuilderInterface { public function build(array $buildSubject): array { $paymentDataObject = SubjectReader::readPayment($buildSubject); $payment = $paymentDataObject->getPayment(); $order = $paymentDataObject->getOrder();

return [ 'TRANSACTION_TYPE' => 'AUTH', 'AMOUNT' => $order->getGrandTotalAmount(), 'CURRENCY' => $order->getCurrencyCode(), 'TOKEN' => $payment->getAdditionalInformation('gateway_token'), 'ORDER_ID' => $order->getOrderIncrementId(), ]; } }

No raw card data appears here. The gateway_token comes from your frontend tokenization step. This keeps you out of PCI SAQ D scope.

Response Validator

<?php

declare(strict_types=1);

namespace Unomage\PaymentGateway\Gateway\Validator;

use Magento\Payment\Gateway\Validator\AbstractValidator; use Magento\Payment\Gateway\Helper\SubjectReader;

class ResponseCodeValidator extends AbstractValidator { private const APPROVED_CODE = 'APPROVED';

public function validate(array $validationSubject): \Magento\Payment\Gateway\Validator\ResultInterface { $response = SubjectReader::readResponse($validationSubject); $responseCode = $response['RESPONSE_CODE'] ?? null;

if ($responseCode === self::APPROVED_CODE) { return $this->createResult(true); }

return $this->createResult(false, [ __('Payment declined: %1', $response['RESPONSE_MESSAGE'] ?? 'Unknown error') ]); } }

The validator returns a typed ResultInterface object. A false result halts the pipeline and surfaces an error to the customer.

Response Handler

Keep each handler small. One handler, one responsibility:

<?php

declare(strict_types=1);

namespace Unomage\PaymentGateway\Gateway\Response;

use Magento\Payment\Gateway\Response\HandlerInterface; use Magento\Payment\Gateway\Helper\SubjectReader;

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

$payment->setTransactionId($response['TRANSACTION_ID']); $payment->setIsTransactionClosed(false); $payment->setAdditionalInformation('gateway_transaction_id', $response['TRANSACTION_ID']); } }

Pro tip: setIsTransactionClosed(false) is critical for authorize-only flows. Setting it to true early breaks capture and void. Magento will not allow actions on a closed transaction.

---

Building the Frontend UI Component

Magento 2 checkout uses RequireJS and KnockoutJS for payment rendering. You need a UI component, a renderer, and a ConfigProvider.

The ConfigProvider exposes safe, public values to the frontend:

<?php

declare(strict_types=1);

namespace Unomage\PaymentGateway\Model\Ui;

use Magento\Checkout\Model\ConfigProviderInterface;

class ConfigProvider implements ConfigProviderInterface { public const CODE = 'unomage_gateway';

public function getConfig(): array { return [ 'payment' => [ self::CODE => [ 'isActive' => true, 'publishableKey' => $this->getPublishableKey(), ], ], ]; } }

The JS renderer collects the token and passes it through additional_data:

define([

'Magento_Checkout/js/view/payment/default', 'mage/url' ], function (Component, urlBuilder) { 'use strict';

return Component.extend({ defaults: { template: 'Unomage_PaymentGateway/payment/form', gatewayToken: '' },

getData: function () { return { method: this.item.method, additional_data: { gateway_token: this.gatewayToken } }; },

validate: function () { return this.gatewayToken !== ''; } }); });

The gateway_token gets populated by your gateway's JS SDK before getData() runs. It travels through additional_data and arrives server-side as payment->getAdditionalInformation('gateway_token') — exactly what the RequestBuilder reads.

Security: Never pass raw card numbers through additional_data. This field must contain an opaque token string only. Log these values at debug level only — never at info or above in production.

---

Testing, Debugging, and Deployment

Debugging the Command Pipeline

When an authorize call fails silently, check in this order:

  • var/log/payment.log — Magento's payment-specific logger
  • var/log/exception.log — caught pipeline exceptions
  • Enable \Magento\Payment\Gateway\Http\Client\Zend logging via virtual type override
  • Check sales_payment_transaction for partial writes
  • Guard debug logging behind a config flag before deploying:

    if ($this->config->getValue('debug')) {
    

    $this->logger->debug('Gateway Request', $sanitized); }

    Testing Strategies

    - Unit tests: Test RequestBuilder, ResponseValidator, and Handler in isolation. Mock PaymentDataObjectInterface with getMockBuilder(). - Integration tests: Use Magento's integration test framework with sandbox credentials in dev/tests/integration/phpunit.xml. - End-to-end: Run a full checkout in staging. Verify that auth, capture, void, and refund all create correct sales_payment_transaction records.

    Production Deployment Checklist

    - [ ] API credentials stored as encrypted config values - [ ] Debug logging disabled or guarded by config flag - [ ] payment_action matches your business flow - [ ] Sandbox mode wired to config, not hardcoded - [ ] Customer-facing exception messages are generic - [ ] CSP whitelist updated for third-party gateway JS domains - [ ] Vault integration considered for saved cards

    Add the CSP whitelist entry. Skipping this causes silent JavaScript failures in production:

    <?xml version="1.0"?>
    

    <csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp/etc/csp_whitelist.xsd"> <policies> <policy id="script-src"> <values> <value id="unomage-gateway-js" type="host">https://js.your-gateway.com</value> </values> </policy> </policies> </csp_whitelist>

    ---

    Building a production-grade custom payment method is more than wiring up an API call. The Gateway Command Pipeline enforces separation between transport, validation, and state mutation. That separation maps directly to PCI-DSS compliance boundaries. Get the architecture right from the start. Keep each class doing one thing. You will have a payment module that is testable, auditable, and maintainable across Magento upgrades.

    The patterns here apply cleanly to Magento 2.4.6 and 2.4.7. If you are integrating Stripe or Braintree, their official extensions follow this same structure. Reading their source code alongside this guide is one of the fastest ways to deepen your understanding of Adobe Commerce payment gateway integration.

    Quality Score: 69/100



    Jot us a note and we’ll get back to you as quickly as possible.
    Copyright © 2021-2026 Unomage, LLC. All rights reserved.