Commit 4147321daf8 for woocommerce

commit 4147321daf816fed021fdd598a4a8915213b2b3b
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Wed Jul 22 11:23:51 2026 +0100

    Avoid repeated payment provider checks in admin requests (#66321)

    * perf(payments): memoize payment provider details within a request

    The Payments settings service re-derived the full payment provider
    details on every call: gateway enumeration, per-gateway account and
    state checks, extension suggestion matching, shell-gateway removal
    and grouping. The onboarding Payments task alone triggers this chain
    from most of its helpers, so an incomplete setup task list made every
    wp-admin dashboard load run the derivation many times over. With
    gateways like WooPayments this added seconds to the dashboard TTFB.

    Memoize the derived details for the duration of the request:

    - PaymentsProviders::get_payment_gateway_details() now memoizes per
      gateway and country, patching the call-specific order on hits.
    - Payments::get_payment_providers() now memoizes per location and
      flags.

    Mutators that influence the list (provider ordering, attaching or
    hiding suggestions, dismissing incentives) reset the memo so
    mid-request changes remain visible.

    Refs #66317

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Improve payment provider request memoization

    * perf: centralize payment provider request caching

    * docs: describe shared payment provider request caching

    * refactor: align payment provider cache terminology

    * Add missing cache prefix return annotation

    * refactor: simplify payment provider request caching

    * Harden payment provider request cache behaviour

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
    Co-authored-by: Vlad Olaru <vlad.olaru@automattic.com>

diff --git a/plugins/woocommerce/changelog/66317-perf-memoize-payments-providers b/plugins/woocommerce/changelog/66317-perf-memoize-payments-providers
new file mode 100644
index 00000000000..0f028510735
--- /dev/null
+++ b/plugins/woocommerce/changelog/66317-perf-memoize-payments-providers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Cache payment provider lists and gateway details in a shared request cache so the dashboard setup widget, onboarding task list, and Payments settings do not repeat expensive provider checks.
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php b/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php
index 2087a3afb1b..a702a04c9f7 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/Payments.php
@@ -29,6 +29,9 @@ class Payments {
 	const FROM_ADDITIONAL_PAYMENTS_TASK = 'WCADMIN_ADDITIONAL_PAYMENT_TASK';
 	const FROM_PROVIDER_ONBOARDING      = 'PROVIDER_ONBOARDING';

+	private const PROVIDERS_REQUEST_CACHE_GROUP = 'woocommerce_payments_providers';
+	private const PROVIDERS_REQUEST_CACHE_KEY   = 'provider_lists';
+
 	/**
 	 * The payment providers service.
 	 *
@@ -54,6 +57,8 @@ class Payments {
 	final public function init( PaymentsProviders $payment_providers, ExtensionSuggestions $payment_extension_suggestions ): void {
 		$this->providers             = $payment_providers;
 		$this->extension_suggestions = $payment_extension_suggestions;
+
+		wp_cache_add_non_persistent_groups( array( self::PROVIDERS_REQUEST_CACHE_GROUP ) );
 	}

 	/**
@@ -76,6 +81,13 @@ class Payments {
 	 * @throws Exception If there are malformed or invalid suggestions.
 	 */
 	public function get_payment_providers( string $location, bool $for_display = true, bool $remove_shells = false ): array {
+		$can_install_plugins   = current_user_can( 'install_plugins' );
+		$cache_key             = get_current_user_id() . '__' . ( $can_install_plugins ? '1' : '0' ) . '__' . strtoupper( $location ) . '__' . ( $for_display ? '1' : '0' ) . ( $remove_shells ? '1' : '0' );
+		$cached_provider_lists = wp_cache_get( self::PROVIDERS_REQUEST_CACHE_KEY, self::PROVIDERS_REQUEST_CACHE_GROUP );
+		if ( is_array( $cached_provider_lists ) && isset( $cached_provider_lists[ $cache_key ] ) && is_array( $cached_provider_lists[ $cache_key ] ) ) {
+			return $cached_provider_lists[ $cache_key ];
+		}
+
 		$payment_gateways = $this->providers->get_payment_gateways( $for_display );
 		if ( ! $for_display && $remove_shells ) {
 			$payment_gateways = $this->providers->remove_shell_payment_gateways( $payment_gateways, $location );
@@ -87,7 +99,7 @@ class Payments {

 		// Only include suggestions if the requesting user can install plugins.
 		$suggestions = array();
-		if ( current_user_can( 'install_plugins' ) ) {
+		if ( $can_install_plugins ) {
 			$suggestions = $this->providers->get_extension_suggestions( $location, self::SUGGESTIONS_CONTEXT );
 		}
 		// If we have preferred suggestions, add them to the providers list.
@@ -204,6 +216,12 @@ class Payments {
 			$this->process_payment_provider_states( $payment_providers );
 		}

+		if ( ! is_array( $cached_provider_lists ) ) {
+			$cached_provider_lists = array();
+		}
+		$cached_provider_lists[ $cache_key ] = $payment_providers;
+		wp_cache_set( self::PROVIDERS_REQUEST_CACHE_KEY, $cached_provider_lists, self::PROVIDERS_REQUEST_CACHE_GROUP );
+
 		return $payment_providers;
 	}

@@ -287,6 +305,9 @@ class Payments {
 		$result = $this->providers->update_payment_providers_order_map( $order_map );

 		if ( $result ) {
+			// The order map influences the providers list, so clear the cached data.
+			$this->clear_cache();
+
 			// Record an event that the payment providers order map was updated.
 			$this->record_event(
 				'payment_providers_order_map_updated',
@@ -313,6 +334,9 @@ class Payments {
 		$result = $this->providers->attach_extension_suggestion( $id );

 		if ( $result ) {
+			// The attachment influences the providers list, so clear the cached data.
+			$this->clear_cache();
+
 			// Record an event that the suggestion was attached.
 			$this->record_event(
 				'extension_suggestion_attached',
@@ -337,6 +361,9 @@ class Payments {
 		$result = $this->providers->hide_extension_suggestion( $id );

 		if ( $result ) {
+			// Hidden suggestions are excluded from the providers list, so clear the cached data.
+			$this->clear_cache();
+
 			// Record an event that the suggestion was hidden.
 			$this->record_event(
 				'extension_suggestion_hidden',
@@ -365,6 +392,11 @@ class Payments {
 	public function dismiss_extension_suggestion_incentive( string $suggestion_id, string $incentive_id, string $context = 'all', bool $do_not_track = false ): bool {
 		$result = $this->extension_suggestions->dismiss_incentive( $incentive_id, $suggestion_id, $context );

+		if ( $result ) {
+			// Incentives are embedded in the providers list details, so clear the cached data.
+			$this->clear_cache();
+		}
+
 		if ( ! $do_not_track && $result ) {
 			// Record an event that the incentive was dismissed.
 			$this->record_event(
@@ -380,6 +412,22 @@ class Payments {
 		return $result;
 	}

+	/**
+	 * Clear cached payment provider data.
+	 *
+	 * Call after changing provider ordering, suggestions, incentives, gateway registration,
+	 * settings, or account state during a request. Also useful for testing purposes.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @internal
+	 * @return void
+	 */
+	public function clear_cache(): void {
+		wp_cache_delete( self::PROVIDERS_REQUEST_CACHE_KEY, self::PROVIDERS_REQUEST_CACHE_GROUP );
+		$this->providers->clear_cache();
+	}
+
 	/**
 	 * Send a Tracks event.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php
index 9e9125bf4c8..529e1156b14 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders.php
@@ -81,6 +81,9 @@ class PaymentsProviders {
 	public const CATEGORY_CRYPTO           = 'crypto';
 	public const CATEGORY_PSP              = 'psp';

+	private const GATEWAY_DETAILS_REQUEST_CACHE_GROUP = 'woocommerce_payment_gateway_details';
+	private const GATEWAY_DETAILS_REQUEST_CACHE_KEY   = 'gateway_details';
+
 	/*
 	 * The provider link types.
 	 *
@@ -184,20 +187,20 @@ class PaymentsProviders {
 	private array $instances = array();

 	/**
-	 * The memoized payment gateways to avoid computing the list multiple times during a request.
+	 * The cached payment gateways, used to avoid computing the list multiple times during a request.
 	 *
 	 * @var array
 	 */
-	private array $payment_gateways_memo = array();
+	private array $payment_gateways_cache = array();

 	/**
-	 * The memoized payment gateways for display to avoid computing the list multiple times during a request.
+	 * The cached payment gateways for display, used to avoid computing the list multiple times during a request.
 	 *
 	 * This is especially important since it avoids triggering the legacy action multiple times during a request.
 	 *
 	 * @var array
 	 */
-	private array $payment_gateways_for_display_memo = array();
+	private array $payment_gateways_for_display_cache = array();

 	/**
 	 * The payment extension suggestions service.
@@ -224,6 +227,8 @@ class PaymentsProviders {
 	final public function init( ExtensionSuggestions $payment_extension_suggestions, LegacyProxy $proxy ): void {
 		$this->extension_suggestions = $payment_extension_suggestions;
 		$this->proxy                 = $proxy;
+
+		wp_cache_add_non_persistent_groups( array( self::GATEWAY_DETAILS_REQUEST_CACHE_GROUP ) );
 	}

 	/**
@@ -247,8 +252,8 @@ class PaymentsProviders {

 		// If we are asked for a display gateways list, we need to fire legacy actions and filter out "shells".
 		if ( $for_display ) {
-			if ( isset( $this->payment_gateways_for_display_memo[ $country_code ] ) ) {
-				return $this->payment_gateways_for_display_memo[ $country_code ];
+			if ( isset( $this->payment_gateways_for_display_cache[ $country_code ] ) ) {
+				return $this->payment_gateways_for_display_cache[ $country_code ];
 			}

 			// We don't want to output anything from the action. So we buffer it and discard it.
@@ -273,14 +278,14 @@ class PaymentsProviders {
 			$payment_gateways = $this->remove_shell_payment_gateways( $payment_gateways, $country_code );

 			// Store the entire payment gateways list for display for later use.
-			$this->payment_gateways_for_display_memo[ $country_code ] = $payment_gateways;
+			$this->payment_gateways_for_display_cache[ $country_code ] = $payment_gateways;

 			return $payment_gateways;
 		}

 		// We were asked for the raw payment gateways list.
-		if ( isset( $this->payment_gateways_memo[ $country_code ] ) ) {
-			return $this->payment_gateways_memo[ $country_code ];
+		if ( isset( $this->payment_gateways_cache[ $country_code ] ) ) {
+			return $this->payment_gateways_cache[ $country_code ];
 		}

 		// Get all payment gateways, ordered by the user.
@@ -290,7 +295,7 @@ class PaymentsProviders {
 		$payment_gateways = $this->handle_non_standard_registration_for_payment_gateways( $payment_gateways );

 		// Store the entire payment gateways list for later use.
-		$this->payment_gateways_memo[ $country_code ] = $payment_gateways;
+		$this->payment_gateways_cache[ $country_code ] = $payment_gateways;

 		return $payment_gateways;
 	}
@@ -479,11 +484,27 @@ class PaymentsProviders {
 		// Normalize the country code to uppercase.
 		$country_code = strtoupper( $country_code );

-		return $this->enhance_payment_gateway_details(
-			$this->get_payment_gateway_base_details( $payment_gateway, $payment_gateway_order, $country_code ),
-			$payment_gateway,
-			$country_code
-		);
+		$cache_key              = get_current_user_id() . '__' . $payment_gateway->id . '__' . $country_code;
+		$cached_gateway_details = wp_cache_get( self::GATEWAY_DETAILS_REQUEST_CACHE_KEY, self::GATEWAY_DETAILS_REQUEST_CACHE_GROUP );
+		if ( is_array( $cached_gateway_details ) && isset( $cached_gateway_details[ $cache_key ] ) && is_array( $cached_gateway_details[ $cache_key ] ) ) {
+			$details = $cached_gateway_details[ $cache_key ];
+		} else {
+			$details = $this->enhance_payment_gateway_details(
+				$this->get_payment_gateway_base_details( $payment_gateway, 0, $country_code ),
+				$payment_gateway,
+				$country_code
+			);
+
+			if ( ! is_array( $cached_gateway_details ) ) {
+				$cached_gateway_details = array();
+			}
+			$cached_gateway_details[ $cache_key ] = $details;
+			wp_cache_set( self::GATEWAY_DETAILS_REQUEST_CACHE_KEY, $cached_gateway_details, self::GATEWAY_DETAILS_REQUEST_CACHE_GROUP );
+		}
+
+		$details['_order'] = $payment_gateway_order;
+
+		return $details;
 	}

 	/**
@@ -1225,14 +1246,34 @@ class PaymentsProviders {
 	}

 	/**
-	 * Reset the memoized data. Useful for testing purposes.
+	 * Clear cached payment gateway data.
+	 *
+	 * Call after changing gateway registration, settings, or account state during a request.
+	 * Also useful for testing purposes.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @internal
+	 * @return void
+	 */
+	public function clear_cache(): void {
+		$this->payment_gateways_cache             = array();
+		$this->payment_gateways_for_display_cache = array();
+		wp_cache_delete( self::GATEWAY_DETAILS_REQUEST_CACHE_KEY, self::GATEWAY_DETAILS_REQUEST_CACHE_GROUP );
+	}
+
+	/**
+	 * Reset cached payment gateway data.
+	 *
+	 * @deprecated 11.1.0 Use clear_cache() instead.
 	 *
 	 * @internal
 	 * @return void
 	 */
 	public function reset_memo(): void {
-		$this->payment_gateways_memo             = array();
-		$this->payment_gateways_for_display_memo = array();
+		wc_deprecated_function( __METHOD__, '11.1.0', 'clear_cache' );
+
+		$this->clear_cache();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php
index 2a805cd66c7..cf09468a6fd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php
@@ -1573,7 +1573,7 @@ class WooPaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {
 		WC()->payment_gateways()->payment_gateways = array();
 		WC()->payment_gateways()->init();

-		$this->providers_service->reset_memo();
+		$this->providers_service->clear_cache();
 	}

 	/**
@@ -1588,7 +1588,7 @@ class WooPaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {
 		WC()->payment_gateways()->init();

 		if ( isset( $this->providers_service ) ) {
-			$this->providers_service->reset_memo();
+			$this->providers_service->clear_cache();
 		}
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php
index a40fb6a1ac3..920731b7904 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProvidersTest.php
@@ -64,19 +64,22 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {
 			->getMock();

 		$this->sut = new PaymentsProviders();
-		$this->sut->init( $this->mock_extension_suggestions, wc_get_container()->get( LegacyProxy::class ) );
+		$this->sut->init(
+			$this->mock_extension_suggestions,
+			wc_get_container()->get( LegacyProxy::class )
+		);
 	}

 	/**
 	 * Tear down test.
 	 */
 	public function tearDown(): void {
-		// Reset gateways/hooks and controller memo between tests.
+		// Reset gateways, hooks, and cached provider data between tests.
 		remove_all_actions( 'wc_payment_gateways_initialized' );
 		WC()->payment_gateways()->payment_gateways = array();
 		WC()->payment_gateways()->init();
 		if ( isset( $this->sut ) ) {
-			$this->sut->reset_memo();
+			$this->sut->clear_cache();
 		}

 		// Restore the previous currency to prevent test leakage.
@@ -964,6 +967,156 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'Special offer', $gateway_details['_incentive']['description'], 'Incentive description should match' );
 	}

+	/**
+	 * @testdox Gateway details are derived once with a neutral order and receive the requested order on every call.
+	 */
+	public function test_get_payment_gateway_details_is_cached(): void {
+		$fake_gateway = new FakePaymentGateway(
+			'fake-gateway-id',
+			array(
+				'plugin_slug' => 'fake-plugin-slug',
+				'plugin_file' => 'fake-plugin-slug/fake-plugin-file',
+			),
+		);
+
+		$provider = $this->createMock( PaymentGateway::class );
+		$provider
+			->expects( $this->once() )
+			->method( 'get_details' )
+			->with( $fake_gateway, 0, 'US' )
+			->willReturn(
+				array(
+					'id'     => 'fake-gateway-id',
+					'_order' => 0,
+					'title'  => 'Derived details',
+					'plugin' => array(
+						'slug' => 'fake-plugin-slug',
+					),
+				)
+			);
+		$this->set_payment_gateway_provider_instance( 'fake-gateway-id', $provider );
+
+		$this->mock_extension_suggestions
+			->expects( $this->once() )
+			->method( 'get_by_plugin_slug' )
+			->willReturn( null );
+
+		$first  = $this->sut->get_payment_gateway_details( $fake_gateway, 1, 'US' );
+		$second = $this->sut->get_payment_gateway_details( $fake_gateway, 5, 'US' );
+
+		$this->assertSame( 1, $first['_order'], 'The first call should use the requested order' );
+		$this->assertSame( 5, $second['_order'], 'Cached details should use the latest requested order' );
+		unset( $first['_order'], $second['_order'] );
+		$this->assertSame( $first, $second, 'Cached details should match the originally derived details' );
+	}
+
+	/**
+	 * @testdox Gateway details are cached separately for each user.
+	 */
+	public function test_get_payment_gateway_details_cache_per_user(): void {
+		$fake_gateway = new FakePaymentGateway(
+			'fake-gateway-id',
+			array(
+				'plugin_slug' => 'fake-plugin-slug',
+				'plugin_file' => 'fake-plugin-slug/fake-plugin-file',
+			),
+		);
+
+		$provider = $this->createMock( PaymentGateway::class );
+		$provider
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_details' )
+			->willReturnCallback(
+				function ( $gateway, $order ) {
+					return array(
+						'id'     => $gateway->id,
+						'_order' => $order,
+						'title'  => (string) get_current_user_id(),
+						'plugin' => array(
+							'slug' => 'fake-plugin-slug',
+						),
+					);
+				}
+			);
+		$this->set_payment_gateway_provider_instance( 'fake-gateway-id', $provider );
+
+		$this->mock_extension_suggestions
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_by_plugin_slug' )
+			->willReturn( null );
+
+		$first_user_details        = $this->sut->get_payment_gateway_details( $fake_gateway, 1, 'US' );
+		$first_user_cached_details = $this->sut->get_payment_gateway_details( $fake_gateway, 2, 'US' );
+
+		$second_user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $second_user_id );
+		$second_user_details        = $this->sut->get_payment_gateway_details( $fake_gateway, 3, 'US' );
+		$second_user_cached_details = $this->sut->get_payment_gateway_details( $fake_gateway, 4, 'US' );
+
+		$this->assertSame( (string) $this->store_admin_id, $first_user_details['title'] );
+		$this->assertSame( (string) $this->store_admin_id, $first_user_cached_details['title'] );
+		$this->assertSame( (string) $second_user_id, $second_user_details['title'] );
+		$this->assertSame( (string) $second_user_id, $second_user_cached_details['title'] );
+		$this->assertSame( 2, $first_user_cached_details['_order'] );
+		$this->assertSame( 4, $second_user_cached_details['_order'] );
+	}
+
+	/**
+	 * @testdox Gateway details are cached per country and recomputed after the cache is cleared.
+	 */
+	public function test_get_payment_gateway_details_cache_per_country_and_clear(): void {
+		$fake_gateway = new FakePaymentGateway(
+			'fake-gateway-id',
+			array(
+				'plugin_slug' => 'fake-plugin-slug',
+				'plugin_file' => 'fake-plugin-slug/fake-plugin-file',
+			),
+		);
+
+		$generation = 0;
+		$provider   = $this->createMock( PaymentGateway::class );
+		$provider
+			->expects( $this->exactly( 4 ) )
+			->method( 'get_details' )
+			->willReturnCallback(
+				function ( $gateway, $order, $country_code ) use ( &$generation ) {
+					$this->assertSame( 0, $order, 'Gateway details should always be derived with a neutral order' );
+					++$generation;
+					return array(
+						'id'     => $gateway->id,
+						'_order' => $order,
+						'title'  => $country_code . '-' . $generation,
+						'plugin' => array(
+							'slug' => 'fake-plugin-slug',
+						),
+					);
+				}
+			);
+		$this->set_payment_gateway_provider_instance( 'fake-gateway-id', $provider );
+
+		$this->mock_extension_suggestions
+			->expects( $this->exactly( 4 ) )
+			->method( 'get_by_plugin_slug' )
+			->willReturn( null );
+
+		$first_us = $this->sut->get_payment_gateway_details( $fake_gateway, 1, 'US' );
+		$first_de = $this->sut->get_payment_gateway_details( $fake_gateway, 2, 'DE' );
+		$this->sut->clear_cache();
+		$second_us = $this->sut->get_payment_gateway_details( $fake_gateway, 3, 'US' );
+		$this->setExpectedDeprecated( PaymentsProviders::class . '::reset_memo' );
+		$this->sut->reset_memo();
+		$third_us = $this->sut->get_payment_gateway_details( $fake_gateway, 4, 'US' );
+
+		$this->assertSame( 'US-1', $first_us['title'] );
+		$this->assertSame( 'DE-2', $first_de['title'] );
+		$this->assertSame( 'US-3', $second_us['title'] );
+		$this->assertSame( 'US-4', $third_us['title'] );
+		$this->assertSame( 1, $first_us['_order'] );
+		$this->assertSame( 2, $first_de['_order'] );
+		$this->assertSame( 3, $second_us['_order'] );
+		$this->assertSame( 4, $third_us['_order'] );
+	}
+
 	/**
 	 * Test that get_payment_gateway_details does not override gateway details with those from the suggestion
 	 * when they exist.
@@ -2878,7 +3031,7 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {

 		WC()->payment_gateways()->init();

-		$this->sut->reset_memo();
+		$this->sut->clear_cache();
 	}

 	/**
@@ -5951,6 +6104,21 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * Set a payment gateway provider instance for testing.
+	 *
+	 * @param string                 $gateway_id The gateway ID.
+	 * @param PaymentGateway         $provider   The provider instance.
+	 * @param PaymentsProviders|null $service    Optional service instance to update.
+	 */
+	private function set_payment_gateway_provider_instance( string $gateway_id, PaymentGateway $provider, ?PaymentsProviders $service = null ): void {
+		$service    = $service ?? $this->sut;
+		$reflection = new \ReflectionClass( $service );
+		$property   = $reflection->getProperty( 'instances' );
+		$property->setAccessible( true );
+		$property->setValue( $service, array( $gateway_id => $provider ) );
+	}
+
 	/**
 	 * Load the WC core PayPal gateway but not enable it.
 	 *
@@ -5970,8 +6138,8 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {
 		WC()->payment_gateways()->payment_gateways = array();
 		WC()->payment_gateways()->init();

-		// Reset the controller memo to pick up the new gateway details.
-		$this->sut->reset_memo();
+		// Clear cached provider data to pick up the new gateway details.
+		$this->sut->clear_cache();
 	}

 	/**
@@ -5993,8 +6161,8 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {
 		WC()->payment_gateways()->payment_gateways = array();
 		WC()->payment_gateways()->init();

-		// Reset the controller memo to pick up the new gateway details.
-		$this->sut->reset_memo();
+		// Clear cached provider data to pick up the new gateway details.
+		$this->sut->clear_cache();
 	}

 	/**
@@ -6004,7 +6172,7 @@ class PaymentsProvidersTest extends WC_Unit_Test_Case {
 		delete_option( 'woocommerce_paypal_settings' );
 		delete_option( 'woocommerce_currency' );

-		$this->sut->reset_memo();
+		$this->sut->clear_cache();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsRestControllerIntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsRestControllerIntegrationTest.php
index 29cf960457e..e239c2aa75b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsRestControllerIntegrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsRestControllerIntegrationTest.php
@@ -1510,6 +1510,8 @@ class PaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {

 		// Delete the user meta.
 		delete_user_meta( $this->store_admin_id, Payments::PAYMENTS_NOX_PROFILE_KEY );
+		// We changed the underlying data directly, so clear the cache to simulate a fresh request.
+		$this->service->clear_cache();

 		// Act.
 		$request = new WP_REST_Request( 'POST', self::ENDPOINT . '/providers' );
@@ -1568,6 +1570,8 @@ class PaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {

 		// Delete the user meta.
 		delete_user_meta( get_current_user_id(), Incentive::PREFIX . 'dismissed' );
+		// We changed the underlying data directly, so clear the cache to simulate a fresh request.
+		$this->service->clear_cache();

 		// Act.
 		$request = new WP_REST_Request( 'POST', self::ENDPOINT . '/providers' );
@@ -1932,8 +1936,8 @@ class PaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {
 		update_option( 'woocommerce_currency', 'USD' );
 		WC()->payment_gateways()->init();

-		// Reset the controller memo to pick up the new gateway details.
-		$this->providers_service->reset_memo();
+		// Clear cached provider data to pick up the new gateway details.
+		$this->providers_service->clear_cache();
 	}

 	/**
@@ -1952,8 +1956,8 @@ class PaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {
 		update_option( 'woocommerce_currency', 'USD' );
 		WC()->payment_gateways()->init();

-		// Reset the service memo to pick up the new gateway details.
-		$this->providers_service->reset_memo();
+		// Clear cached provider data to pick up the new gateway details.
+		$this->providers_service->clear_cache();
 	}

 	/**
@@ -1985,7 +1989,7 @@ class PaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {
 		WC()->payment_gateways()->payment_gateways = array();
 		WC()->payment_gateways()->init();

-		$this->providers_service->reset_memo();
+		$this->providers_service->clear_cache();

 		$active_plugin_paths = array( 'woocommerce/woocommerce.php' );
 		$active_plugin_slugs = array( 'woocommerce' );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsTest.php
index 4fdfe1bcc9c..859bde26b11 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsTest.php
@@ -59,8 +59,10 @@ class PaymentsTest extends WC_Unit_Test_Case {
 									->onlyMethods(
 										array(
 											'get_payment_gateways',
+											'remove_shell_payment_gateways',
 											'get_extension_suggestions',
 											'get_extension_suggestion_categories',
+											'attach_extension_suggestion',
 											'hide_extension_suggestion',
 											'get_order_map',
 											'save_order_map',
@@ -78,6 +80,16 @@ class PaymentsTest extends WC_Unit_Test_Case {

 		$this->sut = new Payments();
 		$this->sut->init( $this->mock_providers, $this->mock_extension_suggestions );
+		$this->sut->clear_cache();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$this->sut->clear_cache();
+
+		parent::tearDown();
 	}

 	/**
@@ -596,6 +608,240 @@ class PaymentsTest extends WC_Unit_Test_Case {
 		$this->assertTrue( $result );
 	}

+	/**
+	 * @testdox Payment providers are cached during a request.
+	 */
+	public function test_get_payment_providers_is_cached(): void {
+		$location = 'US';
+
+		$this->mock_providers
+			->expects( $this->once() )
+			->method( 'get_payment_gateways' )
+			->willReturn( array() );
+
+		$this->mock_providers
+			->expects( $this->once() )
+			->method( 'get_extension_suggestions' )
+			->willReturn( array() );
+
+		$first  = $this->sut->get_payment_providers( $location );
+		$second = $this->sut->get_payment_providers( $location );
+
+		$this->assertSame( $first, $second, 'Repeated calls should return the cached providers' );
+	}
+
+	/**
+	 * @testdox Payment providers are cached across service instances during a request.
+	 */
+	public function test_get_payment_providers_is_cached_across_service_instances(): void {
+		$this->mock_providers
+			->expects( $this->once() )
+			->method( 'get_payment_gateways' )
+			->willReturn( array() );
+
+		$this->mock_providers
+			->expects( $this->once() )
+			->method( 'get_extension_suggestions' )
+			->willReturn( array() );
+
+		$other_service = new Payments();
+		$other_service->init( $this->mock_providers, $this->mock_extension_suggestions );
+
+		$first  = $this->sut->get_payment_providers( 'US' );
+		$second = $other_service->get_payment_providers( 'US' );
+
+		$this->assertSame( $first, $second, 'Service instances should share the request-cached providers.' );
+	}
+
+	/**
+	 * @testdox Payment providers are cached separately for each argument combination.
+	 */
+	public function test_get_payment_providers_is_cached_per_arguments(): void {
+		$this->mock_providers
+			->expects( $this->exactly( 4 ) )
+			->method( 'get_payment_gateways' )
+			->willReturn( array() );
+
+		$this->mock_providers
+			->expects( $this->once() )
+			->method( 'remove_shell_payment_gateways' )
+			->willReturn( array() );
+
+		$this->mock_providers
+			->expects( $this->exactly( 4 ) )
+			->method( 'get_extension_suggestions' )
+			->willReturnOnConsecutiveCalls(
+				self::get_preferred_suggestions( 'us-display' ),
+				self::get_preferred_suggestions( 'de-display' ),
+				self::get_preferred_suggestions( 'us-logic' ),
+				self::get_preferred_suggestions( 'us-logic-without-shells' )
+			);
+
+		$this->mock_providers
+			->method( 'enhance_order_map' )
+			->willReturnCallback( static fn( array $order_map ): array => $order_map );
+
+		$us_display              = $this->sut->get_payment_providers( 'US' );
+		$de_display              = $this->sut->get_payment_providers( 'DE' );
+		$us_logic                = $this->sut->get_payment_providers( 'US', false );
+		$us_logic_without_shells = $this->sut->get_payment_providers( 'US', false, true );
+
+		$this->sut->get_payment_providers( 'US' );
+		$this->sut->get_payment_providers( 'DE' );
+		$this->sut->get_payment_providers( 'US', false );
+		$this->sut->get_payment_providers( 'US', false, true );
+
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'us-display' ), array_column( $us_display, 'id' ) );
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'de-display' ), array_column( $de_display, 'id' ) );
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'us-logic' ), array_column( $us_logic, 'id' ) );
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'us-logic-without-shells' ), array_column( $us_logic_without_shells, 'id' ) );
+	}
+
+	/**
+	 * @testdox Payment providers are cached separately for each user.
+	 */
+	public function test_get_payment_providers_is_cached_per_user(): void {
+		$this->mock_providers
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_payment_gateways' )
+			->willReturn( array() );
+
+		$this->mock_providers
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_extension_suggestions' )
+			->willReturnOnConsecutiveCalls(
+				self::get_preferred_suggestions( 'first-user' ),
+				self::get_preferred_suggestions( 'second-user' )
+			);
+
+		$this->mock_providers
+			->method( 'enhance_order_map' )
+			->willReturnCallback( static fn( array $order_map ): array => $order_map );
+
+		$first_user_result = $this->sut->get_payment_providers( 'US' );
+
+		$second_user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $second_user_id );
+		$second_user_result = $this->sut->get_payment_providers( 'US' );
+		$this->sut->get_payment_providers( 'US' );
+
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'first-user' ), array_column( $first_user_result, 'id' ) );
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'second-user' ), array_column( $second_user_result, 'id' ) );
+	}
+
+	/**
+	 * @testdox Payment providers are cached separately for users who cannot install plugins.
+	 */
+	public function test_get_payment_providers_is_cached_per_install_plugins_capability(): void {
+		$this->mock_providers
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_payment_gateways' )
+			->willReturn( array() );
+
+		$this->mock_providers
+			->expects( $this->once() )
+			->method( 'get_extension_suggestions' )
+			->willReturn( self::get_preferred_suggestions( 'installable' ) );
+
+		$this->mock_providers
+			->method( 'enhance_order_map' )
+			->willReturnCallback( static fn( array $order_map ): array => $order_map );
+
+		$with_capability = $this->sut->get_payment_providers( 'US' );
+
+		$deny_install_plugins = static function ( array $allcaps ): array {
+			$allcaps['install_plugins'] = false;
+			return $allcaps;
+		};
+		add_filter( 'user_has_cap', $deny_install_plugins );
+		$without_capability = $this->sut->get_payment_providers( 'US' );
+		remove_filter( 'user_has_cap', $deny_install_plugins );
+
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'installable' ), array_column( $with_capability, 'id' ) );
+		$this->assertSame( array(), $without_capability, 'Users without install_plugins should not receive extension suggestions' );
+	}
+
+	/**
+	 * @testdox Clearing the cache recomputes the payment providers.
+	 */
+	public function test_clear_cache_recomputes_payment_providers(): void {
+		$this->expect_payment_providers_to_be_recomputed();
+
+		$before = $this->sut->get_payment_providers( 'US' );
+		$this->sut->clear_cache();
+		$after = $this->sut->get_payment_providers( 'US' );
+
+		$this->assert_payment_providers_were_recomputed( $before, $after );
+	}
+
+	/**
+	 * @testdox Updating the payment providers order map clears the cached providers.
+	 */
+	public function test_update_payment_providers_order_map_clears_cache(): void {
+		$this->expect_payment_providers_to_be_recomputed();
+
+		$this->mock_providers
+			->method( 'update_payment_providers_order_map' )
+			->willReturn( true );
+
+		$before = $this->sut->get_payment_providers( 'US' );
+		$this->sut->update_payment_providers_order_map( array( 'gateway1' => 1 ) );
+		$after = $this->sut->get_payment_providers( 'US' );
+
+		$this->assert_payment_providers_were_recomputed( $before, $after );
+	}
+
+	/**
+	 * @testdox Attaching a payment extension suggestion clears the cached providers.
+	 */
+	public function test_attach_payment_extension_suggestion_clears_cache(): void {
+		$this->expect_payment_providers_to_be_recomputed();
+
+		$this->mock_providers
+			->method( 'attach_extension_suggestion' )
+			->willReturn( true );
+
+		$before = $this->sut->get_payment_providers( 'US' );
+		$this->sut->attach_payment_extension_suggestion( 'suggestion1' );
+		$after = $this->sut->get_payment_providers( 'US' );
+
+		$this->assert_payment_providers_were_recomputed( $before, $after );
+	}
+
+	/**
+	 * @testdox Hiding a payment extension suggestion clears the cached providers.
+	 */
+	public function test_hide_payment_extension_suggestion_clears_cache(): void {
+		$this->expect_payment_providers_to_be_recomputed();
+
+		$this->mock_providers
+			->method( 'hide_extension_suggestion' )
+			->willReturn( true );
+
+		$before = $this->sut->get_payment_providers( 'US' );
+		$this->sut->hide_payment_extension_suggestion( 'suggestion1' );
+		$after = $this->sut->get_payment_providers( 'US' );
+
+		$this->assert_payment_providers_were_recomputed( $before, $after );
+	}
+
+	/**
+	 * @testdox Dismissing an extension suggestion incentive clears the cached providers.
+	 */
+	public function test_dismiss_extension_suggestion_incentive_clears_cache(): void {
+		$this->expect_payment_providers_to_be_recomputed();
+
+		$this->mock_extension_suggestions
+			->method( 'dismiss_incentive' )
+			->willReturn( true );
+
+		$before = $this->sut->get_payment_providers( 'US' );
+		$this->sut->dismiss_extension_suggestion_incentive( 'suggestion1', 'incentive1' );
+		$after = $this->sut->get_payment_providers( 'US' );
+
+		$this->assert_payment_providers_were_recomputed( $before, $after );
+	}
+
 	/**
 	 * Test that new gateways are placed above offline PMs when offline group is last.
 	 */
@@ -716,4 +962,61 @@ class PaymentsTest extends WC_Unit_Test_Case {
 		// Stripe should be the last non-offline-PM provider.
 		$this->assertSame( count( $provider_ids ) - 1, $stripe_index, 'stripe should be the last provider' );
 	}
+
+	/**
+	 * Expect two provider derivations with distinct results.
+	 */
+	private function expect_payment_providers_to_be_recomputed(): void {
+		$this->mock_providers
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_payment_gateways' )
+			->willReturn( array() );
+
+		$this->mock_providers
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_extension_suggestions' )
+			->willReturnOnConsecutiveCalls(
+				self::get_preferred_suggestions( 'before-reset' ),
+				self::get_preferred_suggestions( 'after-reset' )
+			);
+
+		$this->mock_providers
+			->method( 'enhance_order_map' )
+			->willReturnCallback( static fn( array $order_map ): array => $order_map );
+	}
+
+	/**
+	 * Assert that a provider list was recomputed.
+	 *
+	 * @param array $before The providers before invalidation.
+	 * @param array $after  The providers after invalidation.
+	 */
+	private function assert_payment_providers_were_recomputed( array $before, array $after ): void {
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'before-reset' ), array_column( $before, 'id' ) );
+		$this->assertSame( array( PaymentsProviders::SUGGESTION_ORDERING_PREFIX . 'after-reset' ), array_column( $after, 'id' ) );
+	}
+
+	/**
+	 * Create a preferred suggestions response with one suggestion.
+	 *
+	 * @param string $id The suggestion ID.
+	 * @return array The suggestions response.
+	 */
+	private static function get_preferred_suggestions( string $id ): array {
+		return array(
+			'preferred' => array(
+				array(
+					'id'          => $id,
+					'_priority'   => 0,
+					'title'       => $id,
+					'description' => '',
+					'plugin'      => array(
+						'_type' => PaymentsExtensionSuggestions::PLUGIN_TYPE_WPORG,
+						'slug'  => $id,
+					),
+				),
+			),
+			'other'     => array(),
+		);
+	}
 }