The same payment option, listed twice at Magento 2 checkout
Ask AI about this article
You switched on the individual payment methods for your gateway, and now the checkout lists card twice. Once inside the big combined button, and once on its own line right underneath it. Nothing errored. No log entry. The store just looks like it cannot count.
Or you have the mirror image of that problem: a payment method you are sure you turned off is still sitting at checkout, and no amount of cache flushing moves it.
Both are the same question underneath. How do you hide a payment method at Magento 2 checkout, for real? Not hide the button. Hide the method.
We shipped a fix for exactly this last week, in a payment gateway module we maintain. The fix was about thirty lines. Finding the right thirty lines is the part worth writing down, because most of the answers you find first are the wrong tool.
Applies to: Magento Open Source 2.4.6 through 2.4.9, Adobe Commerce, and Mage-OS builds. The payment framework described here is the same across all of them.
Short answer: put the rule in a can_use_checkout value handler on your payment method. That is the one place where "should this method appear at checkout" is actually decided, and everything else downstream obeys it.
Why is the same payment method showing twice at checkout?
Because two payment methods are both saying yes, and Magento renders every method that says yes.
The module in our case gives merchants two ways to present the gateway:
- One aggregated method. A single line at checkout. The shopper picks it, lands on the gateway's hosted page, and chooses card, bank transfer or buy now pay later there.
- Individual methods. Card, bank transfer and buy now pay later each get their own line at checkout, and the shopper picks before they leave your store.
Those are two reasonable designs. The bug was that turning on the second one did not turn off the first. The aggregated method had one rule for its availability, "am I switched on in admin", and that rule kept returning true. So checkout showed the combined button and the individual lines, which means card payment appeared twice.
That is a conversion problem, not a cosmetic one. The payment step is where hesitation costs the most, and a shopper who sees the same option twice starts wondering which one is the real one.
How does Magento decide which payment methods appear at checkout?
There is a single chain, and it is short. Follow it once and you will never guess again.
- Checkout asks the quote for its methods. That is
Magento\Quote\Model\PaymentMethodManagement::getList(), which the checkout page hits over REST. - It hands off to
Magento\Payment\Model\MethodList::getAvailableMethods($quote). - For every active method, that method has to pass two gates:
$methodInstance->isAvailable($quote)and$this->_canUseMethod($methodInstance, $quote). _canUseMethod()runs a list of specifications. The first one isCHECK_USE_CHECKOUT.Magento\Payment\Model\Checks\CanUseCheckoutis one line:return $paymentMethod->canUseCheckout();- On a method built with the gateway framework,
Magento\Payment\Model\Method\Adapter::canUseCheckout()is also one line:return (bool)$this->getConfiguredValue('can_use_checkout'); - And
getConfiguredValue()asks your method'sValueHandlerPoolfor the handler registered under the keycan_use_checkout.
So the last word on whether your method appears at checkout is a config value. Config values in the payment gateway framework are objects. Objects you can swap.
There is one detail here that trips people up, and it is worth knowing. Adapter::isAvailable() only runs your availability validator when the payment info instance is set. When MethodList builds the checkout list, it calls setInfoInstance() after the availability check, so at list-build time the info instance is null and the availability validator never runs. That is why the availability validator pool is the wrong place for a rule that has to hide something from the list. can_use_checkout is checked every time.
How do I hide a payment method at Magento 2 checkout?
Add a small class that implements Magento\Payment\Gateway\ConfigInterface, and register it as your method's can_use_checkout handler. That class answers one question, and its answer is the one checkout obeys. Here is ours, with the names made generic:
namespace Vendor\Gateway\Gateway\Config;
use Magento\Payment\Gateway\ConfigInterface;
use Vendor\Gateway\Helper\Config as ConfigHelper;
class CanUseCheckout implements ConfigInterface
{
public function __construct(
private ConfigHelper $config,
private ?string $methodCode = null
) {
}
public function getValue($field, $storeId = null)
{
if ($this->methodCode === null) {
return null;
}
// Only the aggregated method has a rule. Everything else is unchanged.
if ($this->methodCode !== 'vendor_gateway_hosted') {
return true;
}
if (!$this->config->isGatewayActive($storeId)) {
return false;
}
// Hide the aggregated method when the merchant is showing the
// individual ones, has not asked to keep the aggregate as well,
// and has actually selected at least one individual method.
if ($this->config->isIndividualMethodsEnabled($storeId)
&& !$this->config->isAggregatedMethodEnabled($storeId)
&& $this->config->hasIndividualMethodsSelected($storeId)
) {
return false;
}
return true;
}
// setMethodCode() and setPathPattern() come with the interface.
// Both are one-line setters.
}
Then you wire it in etc/di.xml. Three virtual types: your config object bound to a method code, a standard ConfigValueHandler wrapping it, and the handler registered in the pool under can_use_checkout.
<virtualType name="VendorGatewayHostedCanUseCheckoutConfig"
type="Vendor\Gateway\Gateway\Config\CanUseCheckout">
<arguments>
<argument name="methodCode" xsi:type="string">vendor_gateway_hosted</argument>
</arguments>
</virtualType>
<virtualType name="VendorGatewayHostedCanUseCheckoutHandler"
type="Magento\Payment\Gateway\Config\ConfigValueHandler">
<arguments>
<argument name="configInterface" xsi:type="object">VendorGatewayHostedCanUseCheckoutConfig</argument>
</arguments>
</virtualType>
<virtualType name="VendorGatewayHostedValueHandlerPool"
type="Magento\Payment\Gateway\Config\ValueHandlerPool">
<arguments>
<argument name="handlers" xsi:type="array">
<item name="default" xsi:type="string">VendorGatewayHostedConfigValueHandler</item>
<item name="active" xsi:type="string">VendorGatewayHostedIsActiveHandler</item>
<item name="can_use_checkout" xsi:type="string">VendorGatewayHostedCanUseCheckoutHandler</item>
</argument>
</arguments>
</virtualType>
That pool is already on your Magento\Payment\Model\Method\Adapter virtual type as the valueHandlerPool argument. You are adding one key to a structure your module already has. No plugin, no observer, no preference, no core rewrite.
Every individual method gets the same treatment with its own method code, so each one owns its own answer.
Why not just turn the method off in admin?
Because the admin toggle is a single on or off, and the answer you need is conditional.
Switching the aggregated method off in admin does remove it, but it removes it always. The merchant loses the ability to use that mode at all. And on a multi-store setup the two modes often differ per website, so a flat toggle is not even expressive enough.
What the merchant actually wants is: "when I am showing the individual methods, do not also show the combined one, unless I say otherwise." That is a rule, not a switch. Rules live in code, and the switch that feeds them lives in admin.
So we shipped both. The new admin field:
<field id="display_aggregated_payment_option" type="select"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Display the combined payment option</label>
<comment>Show the single combined option in addition to the individual methods below.</comment>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
<config_path>payment/vendor_gateway_advanced/display_aggregated_payment_option</config_path>
<depends>
<field id="*/*/vendor_gateway_advanced/display_individual_payment_options">1</field>
</depends>
</field>
One decision in there is worth copying. In etc/config.xml the new flag defaults to 1, meaning "keep showing the combined option." A merchant who updates the module sees exactly the checkout they saw yesterday, duplicates and all, until they choose otherwise. A payment module that silently changes what a live checkout offers is a much worse bug than the one you are fixing.
Why not hide it with layout XML or a JS mixin?
Because neither of those hides the method. They hide the button.
The checkout payment list is rendered by Knockout from a payload the storefront fetches over REST. You can remove a renderer in checkout_index_index.xml, or write a mixin that filters the observable array, and the line will disappear from the page. Meanwhile the method is still in the list PaymentMethodManagement::getList() returns, and it is still accepted when a payment is set on the quote.
That leaves you with:
- The method still selectable through the REST API and through GraphQL on a headless storefront, where your JavaScript does not run.
- An order that can still be placed on a method you believe is switched off, usually by an integration or a saved flow you forgot about.
- A fix that breaks the next time the checkout markup changes, which for most stores means the next theme update.
Here is the test that settles it. Ask the API what the cart can pay with:
curl -s -H "Authorization: Bearer $CUSTOMER_TOKEN" \
https://your-store.test/rest/V1/carts/mine/payment-methods | jq '.[].code'
For a guest cart, use /rest/V1/guest-carts/<cartId>/payment-methods. If the method code is still in that list, you have not hidden it. If it is gone, it is gone everywhere the storefront can reach.
One scope note while you are there. can_use_checkout is the storefront gate. The admin has its own twin, can_use_internal, checked by Magento\Payment\Model\Checks\CanUseInternal. Hiding a method from the storefront does not hide it from admin order creation, which is usually what you want, and occasionally a surprise.
What about a payment_method_is_active observer?
It works. Adapter::isAvailable() dispatches payment_method_is_active with the result object, the method instance and the quote, and you can flip the result there.
We still would not use it for this. That event fires for every payment method on every checkout render, so your observer opens with a code check to find out whether this call is even its business. You end up with a global listener carrying a rule that belongs to one module, and the next developer has no reason to look in an observer for it.
The value handler is scoped to your method by construction. It sits in your module's Gateway/Config folder, next to the other config objects, which is the first place anyone maintaining a payment module looks.
The observer does earn its place in one case: when you need to change the availability of somebody else's payment method and you cannot edit their di.xml. That is what it is for.
What if availability depends on the cart, not just config?
This is where it gets interesting, and where the honest answer has a caveat in it.
Our rule needed one more clause. Some carts can only be processed through the aggregated hosted flow. In our module that is subscription products, which the gateway only supports on the hosted path. Hiding the aggregated method on a cart like that would leave the shopper with a checkout full of options, none of which can take their order.
So the value object also asks the cart:
// Some carts can only be processed by the aggregated method.
// Never hide it for those, whatever the display settings say.
if ($this->hasSubscriptionInQuote()) {
return true;
}
private function hasSubscriptionInQuote(): bool
{
try {
return $this->subscriptions->hasQuoteSubscription(
$this->checkoutSession->getQuote()
);
} catch (\Exception) {
return false;
}
}
The caveat: ConfigInterface::getValue() receives a field name and a store ID. It does not receive a quote. Reaching for the checkout session inside a config object is a compromise, and it needs that try/catch, because there is no checkout session in an admin order, a cron run, or a CLI command. Failing closed to false there would be the wrong default, so it returns the safe answer instead.
If your rule leans heavily on cart contents rather than config, that is a signal to keep the config half in can_use_checkout and move the cart half into your availability validator, which does get the payment object and can reach the quote through it. Just remember what we covered earlier: the availability validator is skipped when the checkout builds its list, so it cannot be the only gate.
How do you unit test payment method availability?
You do it, is the main point. Availability is a truth table, and truth tables are the cheapest thing in the world to test.
The value object takes one collaborator and returns a boolean. No database, no quote, no Magento bootstrap:
class CanUseCheckoutTest extends TestCase
{
private ConfigHelper&MockObject $config;
protected function setUp(): void
{
$this->config = $this->createMock(ConfigHelper::class);
}
public function testAggregatedMethodIsHiddenWhenIndividualMethodsAreOn(): void
{
$this->config->method('isGatewayActive')->willReturn(true);
$this->config->method('isIndividualMethodsEnabled')->willReturn(true);
$this->config->method('isAggregatedMethodEnabled')->willReturn(false);
$this->config->method('hasIndividualMethodsSelected')->willReturn(true);
$subject = new CanUseCheckout($this->config, 'vendor_gateway_hosted');
$this->assertFalse($subject->getValue('can_use_checkout'));
}
public function testAggregatedMethodStaysWhenNoIndividualMethodIsSelected(): void
{
$this->config->method('isGatewayActive')->willReturn(true);
$this->config->method('isIndividualMethodsEnabled')->willReturn(true);
$this->config->method('isAggregatedMethodEnabled')->willReturn(false);
$this->config->method('hasIndividualMethodsSelected')->willReturn(false);
$subject = new CanUseCheckout($this->config, 'vendor_gateway_hosted');
$this->assertTrue($subject->getValue('can_use_checkout'));
}
}
That second test is the one that matters most, and it is the one nobody writes. It covers the merchant who switches on "show individual methods" and then selects none of them. Without that clause the checkout ends up with no way to pay through the gateway at all: the aggregated method hidden, and nothing individual to replace it. A safeguard with a test on it survives the next refactor. A safeguard with only a comment does not.
The full set covers six paths: method code null, gateway inactive, individual methods off, individual on with the aggregate kept, individual on with the aggregate hidden, and the nothing-selected safeguard. Plus a subscription cart, which must stay available no matter what the display settings say. The config helper gets its own tests for the new scope paths.
Run them the usual way:
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist \
vendor/vendor-name/module-gateway/Test/Unit
These run in CI on every merge request on the module, alongside PHPStan, PHP_CodeSniffer and PHPMD. That is the boring half of maintaining a payment module, and it is the half that decides whether the next release is as safe as the first. It is the same discipline we walk through in how we build Magento payment gateway modules.
What if my method is not built on the gateway adapter?
Older modules extend Magento\Payment\Model\Method\AbstractMethod and override isAvailable() directly. The concept is identical, the seat is different: your conditional goes in that override, and you call parent::isAvailable($quote) first.
AbstractMethod has been deprecated for years, though. If you are already opening the file, the gateway framework with a value handler pool is where this ends up anyway, and the migration is mostly di.xml.
The short version
- The checkout list comes from
MethodList::getAvailableMethods(). Anything that does not change what that returns is decoration. can_use_checkoutis the storefront gate.can_use_internalis the admin one.- Put the rule in a config value object, wire it into the value handler pool under
can_use_checkout, and keep it scoped to your method code. - Give the merchant an admin switch, and default it to today's behavior so nobody's live checkout changes under them.
- Never let a rule hide the last usable payment method. Test that path first.
- Verify with
/V1/carts/mine/payment-methods, not with your eyes on the page.
This is the sort of thing that only turns up after a module has been live for a while and merchants start using the combinations nobody planned for. The other classic version of it is when orders get stuck in pending payment, and it is the same lesson: trust the layer that actually decides, not the one you can see.
If you are a payment provider and your module has grown a few of these, that is exactly what our payment gateway module development work is. And if you are a merchant staring at a checkout that offers to take the money twice, send us what it is doing and we will tell you where in the chain it is being decided.