Commit 90764a38077 for woocommerce
commit 90764a380773a7f506434b95e7461c5853e9a0bc
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date: Thu Apr 30 08:39:05 2026 +0200
[Performance] Reduce the frequency of wc_get_product/wc_get_order calls during checkout (#64418)
Order composition workflows have been updated to inject order and product information into order items. The 'get_product' method for order items has been refactored to use request-level caching for the product object.
diff --git a/plugins/woocommerce/changelog/performance-checkout-thruput-order-model-composition b/plugins/woocommerce/changelog/performance-checkout-thruput-order-model-composition
new file mode 100644
index 00000000000..5fac0595864
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-checkout-thruput-order-model-composition
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Reduced the frequency of wc_get_product and wc_get_order calls during checkout to improve performance.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index e341b5c1338..fd3bdc3db75 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -76,7 +76,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* Order items will be stored here, sometimes before they persist in the DB.
*
* @since 3.0.0
- * @var array<string, array<int, \WC_Order_Item>>
+ * @var array<string, array<\WC_Order_Item>>
*/
protected $items = array();
@@ -954,15 +954,17 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
// on warm post meta caches for products. This addresses scenarios where the order object was not populated during a batch population.
if ( 'line_item' === $type && ! empty( $read_items ) ) {
$product_ids = array_map( static fn( $item ) => $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id(), $read_items );
- $product_ids = array_unique( array_filter( $product_ids ) );
- _prime_post_caches( $product_ids );
+ _prime_post_caches( array_unique( array_filter( $product_ids ) ) );
}
+ // Set the back-reference to the parent order on each loaded item.
+ array_walk( $read_items, fn( $item ) => $item instanceof WC_Order_Item && $item->set_order( $this ) );
+
$this->items[ $group ] = $read_items;
}
// Don't use array_merge here because keys are numeric.
$items = $items + $this->items[ $group ];
- }
+ }//end if
}
return apply_filters( 'woocommerce_order_get_items', $items, $this, $types );
@@ -1165,11 +1167,10 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
// Set parent.
- $item->set_order_id( $this->get_id() );
+ $item instanceof WC_Order_Item ? $item->set_order( $this ) : $item->set_order_id( $this->get_id() );
// Append new row with generated temporary ID.
$item_id = $item->get_id();
-
if ( $item_id ) {
$this->items[ $items_key ][ $item_id ] = $item;
} else {
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index bf4c8284fc6..7fb8497ce06 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Internal\Caches\StaleObjectAttribution;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore as ProductAttributesLookupDataStore;
@@ -33,6 +34,7 @@ require_once WC_ABSPATH . 'includes/legacy/abstract-wc-legacy-product.php';
*/
class WC_Product extends WC_Abstract_Legacy_Product {
use CogsAwareTrait;
+ use StaleObjectAttribution;
/**
* This is the name of this object type.
@@ -146,6 +148,8 @@ class WC_Product extends WC_Abstract_Legacy_Product {
if ( $this->get_id() > 0 ) {
$this->data_store->read( $this );
}
+
+ $this->_woocommerce_object_instantiated();
}
/**
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 4397f008237..dbbb004e34c 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -547,6 +547,8 @@ class WC_Checkout {
'subtotal_tax' => $values['line_subtotal_tax'],
'total_tax' => $values['line_tax'],
'taxes' => $values['line_tax_data'],
+ // Order model compositions: set the order instance (early one, for `set_backorder_meta`).
+ 'order' => $order,
)
);
@@ -557,6 +559,8 @@ class WC_Checkout {
'tax_class' => $product->get_tax_class(),
'product_id' => $product->is_type( ProductType::VARIATION ) ? $product->get_parent_id() : $product->get_id(),
'variation_id' => $product->is_type( ProductType::VARIATION ) ? $product->get_id() : 0,
+ // Order model compositions: set the product instance.
+ 'product' => $product,
)
);
}
diff --git a/plugins/woocommerce/includes/class-wc-order-item-product.php b/plugins/woocommerce/includes/class-wc-order-item-product.php
index 917e0edc4c6..c8abf5d6701 100644
--- a/plugins/woocommerce/includes/class-wc-order-item-product.php
+++ b/plugins/woocommerce/includes/class-wc-order-item-product.php
@@ -56,6 +56,14 @@ class WC_Order_Item_Product extends WC_Order_Item {
),
);
+ /**
+ * The product object is set using set_product or is automatically populated by get_product. This property was introduced
+ * to reduce the number of wc_get_product calls when working with this class in core and extension workflows.
+ *
+ * @var null|false|WC_Product
+ */
+ private $product;
+
/*
|--------------------------------------------------------------------------
| Setters
@@ -92,7 +100,19 @@ class WC_Order_Item_Product extends WC_Order_Item {
if ( $value > 0 && 'product' !== get_post_type( absint( $value ) ) ) {
$this->error( 'order_item_product_invalid_product_id', __( 'Invalid product ID', 'woocommerce' ) );
}
- $this->set_prop( 'product_id', absint( $value ) );
+ $product_id = absint( $value );
+ $this->set_prop( 'product_id', $product_id );
+
+ if ( null !== $this->product ) {
+ // Cached instance invalidation: match the cached product identity against the incoming ID.
+ $cached_product_id = null;
+ if ( $this->product ) {
+ $cached_product_id = $this->product->is_type( ProductType::VARIATION ) ? $this->product->get_parent_id() : $this->product->get_id();
+ }
+ if ( ! $this->product || $cached_product_id !== $product_id ) {
+ $this->product = null;
+ }
+ }
}
/**
@@ -109,7 +129,19 @@ class WC_Order_Item_Product extends WC_Order_Item {
array( 'variation_id' => $value )
);
}
- $this->set_prop( 'variation_id', absint( $value ) );
+ $variation_id = absint( $value );
+ $this->set_prop( 'variation_id', $variation_id );
+
+ if ( null !== $this->product ) {
+ // Cached instance invalidation: match the cached product identity against the incoming ID.
+ $cached_variation_id = null;
+ if ( $this->product ) {
+ $cached_variation_id = $this->product->is_type( ProductType::VARIATION ) ? $this->product->get_id() : 0;
+ }
+ if ( ! $this->product || $cached_variation_id !== $variation_id ) {
+ $this->product = null;
+ }
+ }
}
/**
@@ -244,12 +276,13 @@ class WC_Order_Item_Product extends WC_Order_Item {
}
/**
- * Set properties based on passed in product object.
+ * Aggregate and set properties based on passed in product object.
*
* @param WC_Product $product Product instance.
+ * @return void
*/
public function set_product( $product ) {
- if ( ! is_a( $product, 'WC_Product' ) ) {
+ if ( ! ( $product instanceof \WC_Product ) ) {
$this->error( 'order_item_product_invalid_product', __( 'Invalid product', 'woocommerce' ) );
}
if ( $product->is_type( ProductType::VARIATION ) ) {
@@ -261,6 +294,7 @@ class WC_Order_Item_Product extends WC_Order_Item {
}
$this->set_name( $product->get_name() );
$this->set_tax_class( $product->get_tax_class() );
+ $this->product = $product;
}
/**
@@ -383,21 +417,51 @@ class WC_Order_Item_Product extends WC_Order_Item {
/**
* Get the associated product.
*
+ * @since 10.9.0 returns the same product instance; if the product is updated or deleted, the instance will be re-instantiated.
* @return WC_Product|bool
*/
public function get_product() {
- if ( $this->get_variation_id() ) {
- $product = wc_get_product( $this->get_variation_id() );
- } else {
- $product = wc_get_product( $this->get_product_id() );
+ if ( $this->product ) {
+ $reinstantiate = ! ( $this->product instanceof WC_Product ) ||
+ $this->product->_woocommerce_object_is_stale() ||
+ $this->product->_woocommerce_object_is_dirty();
+ if ( $reinstantiate ) {
+ $this->product = null;
+ }
+ }
+ if ( ! $this->product ) {
+ $product_id = $this->get_variation_id() ? $this->get_variation_id() : $this->get_product_id();
+ $this->product = wc_get_product( $product_id );
}
// Backwards compatible filter from WC_Order::get_product_from_item().
+ /** @var WC_Product|false $product */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+ $product = $this->product;
if ( has_filter( 'woocommerce_get_product_from_item' ) ) {
+ /**
+ * Modifies the product object returned by \WC_Order_Item_Product::get_product.
+ *
+ * @since 10.9.0 the method is caching the product object.
+ * @since 2.7.0 filter introduction.
+ *
+ * @param WC_Product|false $product Product instance.
+ * @param WC_Order_Item_Product $line_item Line item instance.
+ * @param WC_Order $order Order Instance.
+ */
$product = apply_filters( 'woocommerce_get_product_from_item', $product, $this, $this->get_order() );
}
+ /**
+ * Modifies the product object returned by \WC_Order_Item_Product::get_product.
+ *
+ * @since 10.9.0 the method is caching the product object.
+ * @since 2.7.0 filter introduction.
+ *
+ * @param WC_Product|false $product Product instance.
+ * @param WC_Order_Item_Product $line_item Line item instance.
+ */
+ $product = apply_filters( 'woocommerce_order_item_product', $product, $this );
- return apply_filters( 'woocommerce_order_item_product', $product, $this );
+ return $product;
}
/**
diff --git a/plugins/woocommerce/includes/class-wc-order-item.php b/plugins/woocommerce/includes/class-wc-order-item.php
index 7080cb2466b..22858fcd59a 100644
--- a/plugins/woocommerce/includes/class-wc-order-item.php
+++ b/plugins/woocommerce/includes/class-wc-order-item.php
@@ -81,6 +81,15 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
*/
public $legacy_package_key;
+ /**
+ * The order object is set using set_order. This property was introduced to reduce the number
+ * of wc_get_order calls when working with this class in core and extension workflows.
+ * Stored as a WeakReference to avoid circular reference memory retention in batch/CLI workflows.
+ *
+ * @var null|\WeakReference<\WC_Order>
+ */
+ private $order;
+
/**
* Constructor.
*
@@ -191,10 +200,12 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
/**
* Get parent order object.
*
+ * @since 10.9.0 returns the same order instance associated with the item; previously, a new instance was created on each call.
* @return WC_Order
*/
public function get_order() {
- return wc_get_order( $this->get_order_id() );
+ $order = $this->order ? $this->order->get() : null;
+ return $order ?? wc_get_order( $this->get_order_id() );
}
/*
@@ -209,7 +220,31 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
* @param int $value Order ID.
*/
public function set_order_id( $value ) {
- $this->set_prop( 'order_id', absint( $value ) );
+ $order_id = absint( $value );
+ $this->set_prop( 'order_id', $order_id );
+
+ $order = $this->order ? $this->order->get() : null;
+ if ( null !== $order && $order->get_id() !== $order_id ) {
+ $this->order = null;
+ }
+ }
+
+ /**
+ * Aggregate and set properties based on passed in order object.
+ *
+ * @since 10.9.0
+ * @param WC_Abstract_Order $order Order instance.
+ * @return void
+ */
+ public function set_order( $order ) {
+ if ( ! ( $order instanceof \WC_Abstract_Order ) ) {
+ $this->error( 'order_item_invalid_order', __( 'Invalid order', 'woocommerce' ) );
+ }
+ $this->set_order_id( $order->get_id() );
+ // Don't inject the refund object: get_order() declares a WC_Order return type; WC_Order_Refund would violate that contract.
+ if ( $order instanceof \WC_Order ) {
+ $this->order = \WeakReference::create( $order );
+ }
}
/**
diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index 3326045eed1..cf13876717a 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -39,6 +39,7 @@ class WC_Post_Data {
public static function init() {
add_action( 'clean_post_cache', array( __CLASS__, 'invalidate_products_last_modified' ), 10, 2 );
add_action( 'clean_post_cache', array( __CLASS__, 'invalidate_db_block_templates_cache' ), 10, 2 );
+ add_action( 'clean_post_cache', array( __CLASS__, 'update_stale_product_objects_tracking_cache' ), 10, 2 );
add_filter( 'post_type_link', array( __CLASS__, 'variation_post_link' ), 10, 2 );
add_action( 'shutdown', array( __CLASS__, 'do_deferred_product_sync' ), 10 );
add_action( 'set_object_terms', array( __CLASS__, 'force_default_term' ), 10, 5 );
@@ -208,6 +209,24 @@ class WC_Post_Data {
}
}
+ /**
+ * Updates product save/delete operation timestamp as part of stale objects trackings.
+ *
+ * @param int $post_id Post ID.
+ * @param \WP_Post $post Post object.
+ *
+ * @internal
+ * @since 10.9.0
+ *
+ * @return void
+ */
+ public static function update_stale_product_objects_tracking_cache( $post_id, $post ): void {
+ if ( $post instanceof \WP_Post && in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) {
+ // Not the greatest design, but we need access to non-static 'object_type' property of the product object.
+ ( new WC_Product() )->_woocommerce_entity_persisted( $post_id );
+ }
+ }
+
/**
* Handle type changes.
*
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0d8494c76ca..4199cbe80ee 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -12786,12 +12786,6 @@ parameters:
count: 1
path: includes/class-wc-order-item-product.php
- -
- message: '#^Method WC_Order_Item_Product\:\:set_product\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/class-wc-order-item-product.php
-
-
message: '#^Method WC_Order_Item_Product\:\:set_product_id\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Caches/StaleObjectAttribution.php b/plugins/woocommerce/src/Internal/Caches/StaleObjectAttribution.php
new file mode 100644
index 00000000000..47df90f8aa8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/StaleObjectAttribution.php
@@ -0,0 +1,78 @@
+<?php declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+/**
+ * Tracks stale object instances. The decentralized hooks architecture allows multiple modification routes in both
+ * WooCommerce core and its extensions. This feature enables the identification of stale objects under those constraints.
+ */
+trait StaleObjectAttribution {
+ /**
+ * Object instantiation timestamp.
+ *
+ * @var float|null
+ */
+ private ?float $_woocommerce_instance_created_at = null; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
+
+ /**
+ * Registry of persistence (update/delete) operations on the ID-level.
+ *
+ * @var array<string,float>
+ */
+ private static array $_woocommerce_entity_persisted_at = array(); // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
+
+ /**
+ * Tracks object instantiation timestamp.
+ *
+ * @return void
+ */
+ private function _woocommerce_object_instantiated(): void { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+ $this->_woocommerce_instance_created_at = microtime( true );
+ }
+
+ /**
+ * Tracks object persistence (update/delete) timestamp.
+ *
+ * Do not use this method for customization. Although it is public due to architectural constraints, improper use
+ * may result in stale instance usage, which is critical for functions such as product inventory management.
+ *
+ * @param int $entity_id Object ID.
+ * @return void
+ */
+ public function _woocommerce_entity_persisted( int $entity_id ): void { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+ $object_type = $this instanceof \WC_Product ? 'product' : $this->object_type;
+ self::$_woocommerce_entity_persisted_at[ $object_type . ':' . $entity_id ] = microtime( true );
+ }
+
+ /**
+ * Enables verification of whether the object instance is stale. If it is stale, the object must be re-created,
+ * as the current architecture does not support refreshing object attributes without replacing the object.
+ *
+ * @return bool
+ */
+ public function _woocommerce_object_is_stale(): bool { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+ // Unknown instantiation timestamp means freshness cannot be determined — treat as stale to fail safe.
+ if ( null !== $this->_woocommerce_instance_created_at ) {
+ $instance_timestamp = (float) ( $this->_woocommerce_instance_created_at );
+ $object_type = $this instanceof \WC_Product ? 'product' : $this->object_type;
+ $entity_persisted_at = (float) ( self::$_woocommerce_entity_persisted_at[ $object_type . ':' . $this->get_id() ] ?? null );
+
+ return $instance_timestamp <= $entity_persisted_at;
+ }
+
+ return true;
+ }
+
+ /**
+ * Enables verification of whether the object instance is modified.
+ *
+ * @return bool
+ */
+ public function _woocommerce_object_is_dirty(): bool { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
+ $meta_data = $this->meta_data ?? array();
+ $is_dirty = ! empty( $this->changes );
+ $is_dirty = $is_dirty || ! empty( array_filter( $meta_data, static fn( $meta ) => ! $meta->id || ! empty( $meta->get_changes() ) ) );
+
+ return $is_dirty;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
index 28ca201f5ad..98e3bc7a554 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
@@ -167,6 +167,7 @@ class OrderItemSchema extends AbstractLineItemSchema {
public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
$dp = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
$quantity_amount = (float) $order_item->get_quantity();
+ $currency = $order_item->get_order()->get_currency();
$data = array(
'id' => $order_item->get_id(),
'name' => $order_item->get_name(),
@@ -182,8 +183,8 @@ class OrderItemSchema extends AbstractLineItemSchema {
'total_tax' => wc_format_decimal( $order_item->get_total_tax(), $dp ),
'taxes' => $this->prepare_taxes( $order_item, $request ),
'meta_data' => $this->prepare_meta_data( $order_item ),
- 'currency' => $order_item->get_order()->get_currency(),
- 'currency_symbol' => html_entity_decode( get_woocommerce_currency_symbol( $order_item->get_order()->get_currency() ), ENT_QUOTES ),
+ 'currency' => $currency,
+ 'currency_symbol' => html_entity_decode( get_woocommerce_currency_symbol( $currency ), ENT_QUOTES ),
);
// Add COGS data.
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php b/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php
index 13c354e512b..1e04bccbd5c 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/order-items/class-wc-tests-order-item-product.php
@@ -147,6 +147,62 @@ class WC_Tests_Order_Item_Product extends WC_Unit_Test_Case {
$this->assertEquals( $variation_product->get_id(), $retrieved->get_id() );
}
+ /**
+ * Validates the product instance caching behaviour.
+ */
+ public function test_get_get_product_caching(): void {
+ $product_item = new WC_Order_Item_Product();
+ $product = new WC_Product_Simple();
+ $product->save();
+
+ // Verify: cached instance unchanged after the initial injection.
+ $instance = wc_get_product( $product->get_id() );
+ $product_item->set_product( $instance );
+ $this->assertSame( $product->get_id(), $product_item->get_product_id() );
+ $this->assertSame( $instance, $product_item->get_product() );
+
+ // Verify: cached instance refresh when product is saved.
+ $instance = $product_item->get_product();
+ $instance->set_stock_quantity( 10 );
+ $instance->save();
+ $this->assertNotSame( $instance, $product_item->get_product() );
+
+ // Verify: cached instance refresh after unsaved property mutation.
+ $instance = $product_item->get_product();
+ $instance->set_price( 10.99 );
+ $this->assertNotSame( $instance, $product_item->get_product() );
+
+ // Verify: cached instance refresh after unsaved meta addition.
+ $instance = $product_item->get_product();
+ $instance->add_meta_data( 'key', 'add' );
+ $this->assertNotSame( $instance, $product_item->get_product() );
+ $instance->save();
+
+ // Verify: cached instance refresh after unsaved meta update.
+ $instance = $product_item->get_product();
+ $instance->update_meta_data( 'key', 'update' );
+ $this->assertNotSame( $instance, $product_item->get_product() );
+ $instance->save();
+
+ // Verify: cached instance refresh after unsaved meta delete.
+ $instance = $product_item->get_product();
+ $instance->delete_meta_data( 'key' );
+ $this->assertNotSame( $instance, $product_item->get_product() );
+
+ // Verify: cached instance refresh when product is deleted.
+ $product->delete( true );
+ $this->assertFalse( $product_item->get_product() );
+
+ $replacement = new WC_Product_Simple();
+ $replacement->save();
+
+ // Verify: cached instance invalidated when updating product ID.
+ $product_item->set_product_id( $replacement->get_id() );
+ $this->assertSame( $replacement->get_id(), $product_item->get_product()->get_id() );
+
+ $replacement->delete();
+ }
+
/**
* Test get_item_download_url method for WC_Order_Item_Product.
*