Payment Methods
Magento payment methods are built by configuring the core adapter with gateway commands, not by writing MethodInterface.
<virtualType name="VendorPayFacade" type="Magento\Payment\Model\Method\Adapter">A payment method in Magento 2.4.9 is any class implementing Magento\Payment\Model\MethodInterface. In practice you do not write that class. You configure a copy of Magento\Payment\Model\Method\Adapter as a virtual type, a named pre-configured instance of an existing class, and plug small single-purpose classes into it. That set of pieces is the gateway framework, and it lives entirely in Magento Open Source.
The pieces
A command is one gateway operation: authorize, capture, void, refund. Each is a Magento\Payment\Gateway\Command\GatewayCommand virtual type wired from four interfaces. Request\BuilderInterface::build(array $buildSubject) turns the payment into an array. Http\TransferFactoryInterface::create(array $request) wraps it. Http\ClientInterface::placeRequest() sends it. Response\HandlerInterface::handle(array $handlingSubject, array $response) writes the result back onto the payment. A Validator\ValidatorInterface can reject the response before the handler sees it. Admin settings reach the method through a Config\ValueHandlerPool reading payment/<code>/*.
The vault
The vault stores a reusable gateway token as a Magento\Vault\Api\Data\PaymentTokenInterface row: a public hash, the gateway token, an expiry and masked card details. A returning customer then pays without re-entering the card. Tokens hang off a customer id, so a guest checkout produces none.
What a real integration must handle
Authorize-only as well as authorize-and-capture; partial capture and partial refund; void before settlement; a redirect or 3-D Secure step that takes the browser away and returns; and an asynchronous webhook that arrives after the order already exists.
Virtual types are resolved from generated code in production mode, so run bin/magento setup:di:compile after editing di.xml, and bin/magento cache:flush config after config.xml.
Example
<!-- app/code/Vendor/Pay/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- The payment method itself: the core adapter, configured. -->
<virtualType name="VendorPayFacade" type="Magento\Payment\Model\Method\Adapter">
<arguments>
<argument name="code" xsi:type="string">vendor_pay</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>
<!-- Reads payment/vendor_pay/* from configuration. -->
<virtualType name="VendorPayConfig" type="Magento\Payment\Gateway\Config\Config">
<arguments>
<argument name="methodCode" xsi:type="string">vendor_pay</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>
<!-- One command per gateway operation. -->
<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\Pay\Gateway\Request\AuthorizeRequest</argument>
<argument name="transferFactory" xsi:type="object">Vendor\Pay\Gateway\Http\TransferFactory</argument>
<argument name="client" xsi:type="object">Vendor\Pay\Gateway\Http\Client</argument>
<argument name="handler" xsi:type="object">Vendor\Pay\Gateway\Response\TxnIdHandler</argument>
</arguments>
</virtualType>
</config>
When to use it
- Integrate a regional card gateway that has no Magento extension.
- Add a capture command so the merchant can authorise now and charge on shipment.
- Store a token so returning customers can reorder without re-entering a card.
- Trace which class built the request a gateway rejected.
More examples
Pointing the method code at the virtual type
The model node holds the virtual type name, not a real class. That is the join between configuration and the di.xml wiring, and it is the usual reason a method never appears at checkout. Note how xsi:type differs by position in di.xml: a direct argument uses object, while a class or virtual type named inside an array uses string. Both resolve to the same instance. A separate etc/payment.xml declares the method's group and card types for the admin screens. Apply with bin/magento cache:flush config.
<!-- app/code/Vendor/Pay/etc/config.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<payment>
<vendor_pay>
<active>0</active>
<model>VendorPayFacade</model>
<title>Card</title>
<payment_action>authorize</payment_action>
<order_status>pending_payment</order_status>
<currency>EUR</currency>
<can_authorize>1</can_authorize>
<can_capture>1</can_capture>
<can_refund>1</can_refund>
<can_void>1</can_void>
</vendor_pay>
</payment>
</default>
</config>
Building the gateway request
Builders are the readable part of a gateway integration: one class, one array, no HTTP. SubjectReader is the supported way to pull the payment and amount out of the untyped $buildSubject array, and it throws InvalidArgumentException rather than returning null when a key is missing. Several builders can be composed with Magento\Payment\Gateway\Request\BuilderComposite when one request has distinct sections, such as customer, address and card.
<?php
// app/code/Vendor/Pay/Gateway/Request/AuthorizeRequest.php
declare(strict_types=1);
namespace Vendor\Pay\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();
$payment = $paymentDO->getPayment();
return [
'txn_type' => 'authorize',
'amount' => sprintf('%.2F', (float) $amount),
'currency' => $order->getCurrencyCode(),
'reference' => $order->getOrderIncrementId(),
'nonce' => (string) $payment->getAdditionalInformation('vendor_pay_nonce'),
];
}
}
Writing the gateway answer back onto the payment
Everything the merchant later needs, in the admin or in a refund, comes from what this handler stores. Leaving the transaction open is what makes Invoice and Credit Memo work against the original authorisation; closing it means every later operation has to start a fresh gateway transaction. Chain several handlers with Magento\Payment\Gateway\Response\HandlerChain when one response updates the payment, the fraud status and a vault token.
<?php
// app/code/Vendor/Pay/Gateway/Response/TxnIdHandler.php
declare(strict_types=1);
namespace Vendor\Pay\Gateway\Response;
use Magento\Payment\Gateway\Helper\SubjectReader;
use Magento\Payment\Gateway\Response\HandlerInterface;
use Magento\Sales\Model\Order\Payment;
class TxnIdHandler implements HandlerInterface
{
public function handle(array $handlingSubject, array $response): void
{
$paymentDO = SubjectReader::readPayment($handlingSubject);
/** @var Payment $payment */
$payment = $paymentDO->getPayment();
$payment->setTransactionId((string) ($response['txn_id'] ?? ''));
$payment->setCcTransId((string) ($response['txn_id'] ?? ''));
// An authorisation is not final, so the transaction stays open for a later capture.
$payment->setIsTransactionClosed(false);
$payment->setShouldCloseParentTransaction(false);
}
}
Discussion