From 9eb45472a916f55e71c8f1083da8675ea2a57c52 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 21 May 2026 09:38:41 +0100 Subject: [PATCH 1/8] wip --- src/base/ShippingMethod.php | 6 +-- src/models/ShippingRule.php | 67 ++++++++++++++----------- src/services/ShippingMethods.php | 5 +- src/services/ShippingRuleCategories.php | 43 ++++++++++++++-- 4 files changed, 83 insertions(+), 38 deletions(-) diff --git a/src/base/ShippingMethod.php b/src/base/ShippingMethod.php index e4a5600b46..e98d0b90b8 100644 --- a/src/base/ShippingMethod.php +++ b/src/base/ShippingMethod.php @@ -275,10 +275,8 @@ public function matchOrder(Order $order): bool } /** @var ShippingRuleInterface $rule */ - foreach ($this->getShippingRules()->all() as $rule) { - if ($rule->matchOrder($order)) { - return true; - } + if ($this->getMatchingShippingRule($order)) { + return true; } return false; diff --git a/src/models/ShippingRule.php b/src/models/ShippingRule.php index 3fbc3f45f5..ecbee6ea7b 100644 --- a/src/models/ShippingRule.php +++ b/src/models/ShippingRule.php @@ -117,20 +117,20 @@ class ShippingRule extends Model implements ShippingRuleInterface, HasStoreInter private ?array $_shippingRuleCategories = null; /** - * @var ShippingRuleOrderCondition|null + * @var string|array|ShippingRuleOrderCondition|null * @see setOrderCondition() * @see getOrderCondition() * @since 5.0.0 */ - private ?ShippingRuleOrderCondition $_orderCondition = null; + private ShippingRuleOrderCondition|string|array|null $_orderCondition = null; /** - * @var ShippingRuleCustomerCondition|null + * @var string|array|ShippingRuleCustomerCondition|null * @see setCustomerCondition() * @see getCustomerCondition() * @since 5.4.0 */ - private ?ShippingRuleCustomerCondition $_customerCondition = null; + private ShippingRuleCustomerCondition|string|array|null $_customerCondition = null; /** * @throws InvalidConfigException @@ -248,17 +248,6 @@ public function setOrderCondition(ShippingRuleOrderCondition|string|array|null $ return; } - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ShippingRuleOrderCondition) { - $condition['class'] = ShippingRuleOrderCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var ShippingRuleOrderCondition $condition */ - } - $condition->forProjectConfig = false; - $this->_orderCondition = $condition; } @@ -268,12 +257,26 @@ public function setOrderCondition(ShippingRuleOrderCondition|string|array|null $ */ public function getOrderCondition(): ShippingRuleOrderCondition { - $condition = $this->_orderCondition ?? new ShippingRuleOrderCondition(); + if ($this->_orderCondition instanceof ShippingRuleOrderCondition) { + return $this->_orderCondition; + } + + $condition = $this->_orderCondition ?? []; + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + $condition['class'] = ShippingRuleOrderCondition::class; + $condition = Craft::$app->getConditions()->createCondition($condition); + /** @var ShippingRuleOrderCondition $condition */ + $condition->forProjectConfig = false; $condition->mainTag = 'div'; $condition->name = 'orderCondition'; $condition->storeId = $this->storeId; - return $condition; + $this->_orderCondition = $condition; + + return $this->_orderCondition; } /** @@ -284,31 +287,39 @@ public function getOrderCondition(): ShippingRuleOrderCondition */ public function setCustomerCondition(ShippingRuleCustomerCondition|string|array|null $condition): void { - if (is_string($condition)) { - $condition = Json::decodeIfJson($condition); - } - - if (!$condition instanceof ShippingRuleCustomerCondition) { - $condition['class'] = ShippingRuleCustomerCondition::class; - $condition = Craft::$app->getConditions()->createCondition($condition); - /** @var ShippingRuleCustomerCondition $condition */ + if (empty($condition)) { + $this->_customerCondition = null; + return; } - $condition->forProjectConfig = false; $this->_customerCondition = $condition; } /** * @return ShippingRuleCustomerCondition + * @throws InvalidConfigException * @since 5.4.0 */ public function getCustomerCondition(): ShippingRuleCustomerCondition { - $condition = $this->_customerCondition ?? new ShippingRuleCustomerCondition(); + if ($this->_customerCondition instanceof ShippingRuleCustomerCondition) { + return $this->_customerCondition; + } + + $condition = $this->_customerCondition ?? []; + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + $condition['class'] = ShippingRuleCustomerCondition::class; + $condition = Craft::$app->getConditions()->createCondition($condition); + /** @var ShippingRuleCustomerCondition $condition */ + $condition->forProjectConfig = false; $condition->mainTag = 'div'; $condition->name = 'customerCondition'; + $this->_customerCondition = $condition; - return $condition; + return $this->_customerCondition; } /** diff --git a/src/services/ShippingMethods.php b/src/services/ShippingMethods.php index ce492a9300..4145c48025 100644 --- a/src/services/ShippingMethods.php +++ b/src/services/ShippingMethods.php @@ -138,9 +138,10 @@ public function getMatchingShippingMethods(Order $order): array /** @var ShippingMethod $method */ foreach ($event->getShippingMethods() as $method) { - $totalPrice = $method->getPriceForOrder($order); - if ($method->getIsEnabled() && $method->matchOrder($order)) { + // Now we know the method matches, let's get the price + $totalPrice = $method->getPriceForOrder($order); + $matchingMethods[$method->getHandle()] = [ 'method' => $method, 'price' => $totalPrice, // Store the price so we can sort on it before returning diff --git a/src/services/ShippingRuleCategories.php b/src/services/ShippingRuleCategories.php index 26a1510232..f1a9b96038 100644 --- a/src/services/ShippingRuleCategories.php +++ b/src/services/ShippingRuleCategories.php @@ -12,6 +12,7 @@ use craft\commerce\models\ShippingRuleCategory; use craft\commerce\records\ShippingRuleCategory as ShippingRuleCategoryRecord; use craft\db\Query; +use Illuminate\Support\Collection; use Throwable; use yii\base\Component; use yii\db\StaleObjectException; @@ -24,6 +25,30 @@ */ class ShippingRuleCategories extends Component { + private ?array $_shippingRuleCategories = null; + + public function getAllShippingRuleCategoriesData(): array + { + if ($this->_shippingRuleCategories === null) { + $data = $this->_createShippingRuleCategoriesQuery()->all(); + + if (!empty($data)) { + $ruleCategories = []; + foreach ($data as $row) { + if (!isset($ruleCategories[$row['shippingRuleId']])) { + $ruleCategories[$row['shippingRuleId']] = []; + } + + $ruleCategories[$row['shippingRuleId']][$row['shippingCategoryId']] = $row; + } + + $this->_shippingRuleCategories = $ruleCategories; + } + } + + return $this->_shippingRuleCategories ?? []; + } + /** * Returns an array of shipping rules categories per the rule's ID. * @@ -34,15 +59,22 @@ public function getShippingRuleCategoriesByRuleId(int $ruleId): array { $rules = []; - $rows = $this->_createShippingRuleCategoriesQuery() - ->where(['shippingRuleId' => $ruleId]) - ->all(); + $shippingRuleCategories = $this->getAllShippingRuleCategoriesData(); + if (!isset($shippingRuleCategories[$ruleId])) { + return []; + } + + foreach ($shippingRuleCategories[$ruleId] as $row) { + if ($row instanceof ShippingRuleCategory) { + continue; + } - foreach ($rows as $row) { $id = $row['shippingCategoryId']; $rules[$id] = new ShippingRuleCategory($row); } + $this->_shippingRuleCategories[$ruleId] = $rules; + return $rules; } @@ -127,6 +159,9 @@ public function deleteShippingRuleCategoryById(int $id): bool $record = ShippingRuleCategoryRecord::findOne($id); if ($record) { + // Clear cache if required + unset($this->_shippingRuleCategories[$record->shippingRuleId]); + return (bool)$record->delete(); } From fb197b4aa5d6af7e3c65000d4f693c759b937381 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 21 May 2026 11:30:17 +0100 Subject: [PATCH 2/8] =?UTF-8?q?Do=20not=20try=20and=20retrieve=20shipping?= =?UTF-8?q?=20method/rule=20price=20if=20it=20doesn=E2=80=99t=20match=20th?= =?UTF-8?q?e=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/elements/Order.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/elements/Order.php b/src/elements/Order.php index 58e137ae6e..51c6b47ae0 100644 --- a/src/elements/Order.php +++ b/src/elements/Order.php @@ -2191,13 +2191,14 @@ public function getAvailableShippingMethodOptions(): array } } + $matchesOrder = ArrayHelper::isIn($method->getHandle(), $matchingMethodHandles); $option->setOrder($this); $option->enabled = $method->getIsEnabled(); $option->id = $method->getId(); $option->name = $method->getName(); $option->handle = $method->getHandle(); - $option->matchesOrder = ArrayHelper::isIn($method->getHandle(), $matchingMethodHandles); - $option->price = $method->getPriceForOrder($this); + $option->matchesOrder = $matchesOrder; + $option->price = $matchesOrder ? $method->getPriceForOrder($this) : 0; $option->shippingMethod = $method; $option->storeId = $storeId; From ad038f58afbb5fbdf449c6e9c6e736a626ac398b Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 21 May 2026 11:30:46 +0100 Subject: [PATCH 3/8] =?UTF-8?q?Making=20sure=20we=20aren=E2=80=99t=20retur?= =?UTF-8?q?ning=20disabled=20shipping=20methods=20for=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/elements/Order.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/elements/Order.php b/src/elements/Order.php index 51c6b47ae0..63183cbd1f 100644 --- a/src/elements/Order.php +++ b/src/elements/Order.php @@ -2169,7 +2169,11 @@ public function getAvailableShippingMethodOptions(): array // Get all regular methods and add them to the list, for use only when the order is complete. if ($this->isCompleted) { - $allShippingMethods = ArrayHelper::index(Plugin::getInstance()->getShippingMethods()->getAllShippingMethods()->all(), fn(ShippingMethodInterface $sm) => $sm->getHandle()); + $allShippingMethods = Plugin::getInstance()->getShippingMethods()->getAllShippingMethods() + ->keyBy(fn(ShippingMethodInterface $sm) => $sm->getHandle()) + ->filter(fn(ShippingMethodInterface $sm) => $sm->getIsEnabled()) + ->all(); + $methods = ArrayHelper::merge($allShippingMethods, $methods); } From 4835841e3b9cccdde18b23805cf1ad56478db7b4 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 21 May 2026 11:57:57 +0100 Subject: [PATCH 4/8] memoize matching shipping rule --- src/base/ShippingMethod.php | 22 +++++++++++++++++++--- src/services/ShippingMethods.php | 5 +++++ src/services/ShippingRuleCategories.php | 1 - 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/base/ShippingMethod.php b/src/base/ShippingMethod.php index e98d0b90b8..319146eb5e 100644 --- a/src/base/ShippingMethod.php +++ b/src/base/ShippingMethod.php @@ -93,6 +93,11 @@ abstract class ShippingMethod extends BaseModel implements ShippingMethodInterfa */ public ?DateTime $dateUpdated = null; + /** + * @var array + */ + private array $_matchingRuleByOrderNumber = []; + /** * @inheritdoc */ @@ -274,7 +279,6 @@ public function matchOrder(Order $order): bool return false; } - /** @var ShippingRuleInterface $rule */ if ($this->getMatchingShippingRule($order)) { return true; } @@ -287,14 +291,26 @@ public function matchOrder(Order $order): bool */ public function getMatchingShippingRule(Order $order): ?ShippingRuleInterface { + if (array_key_exists($order->number, $this->_matchingRuleByOrderNumber)) { + return $this->_matchingRuleByOrderNumber[$order->number]; + } + foreach ($this->getShippingRules() as $rule) { /** @var ShippingRuleInterface $rule */ if ($rule->matchOrder($order)) { - return $rule; + return $this->_matchingRuleByOrderNumber[$order->number] = $rule; } } - return null; + return $this->_matchingRuleByOrderNumber[$order->number] = null; + } + + /** + * @return void + */ + public function clearMatchingShippingRuleCache(): void + { + $this->_matchingRuleByOrderNumber = []; } public function getPriceForOrder(Order $order): float diff --git a/src/services/ShippingMethods.php b/src/services/ShippingMethods.php index 4145c48025..6adaf9582a 100644 --- a/src/services/ShippingMethods.php +++ b/src/services/ShippingMethods.php @@ -156,6 +156,11 @@ public function getMatchingShippingMethods(Order $order): array foreach ($matchingMethods as $shippingMethod) { $method = $shippingMethod['method']; $shippingMethods[$method->getHandle()] = $method; // Keep the key being the handle of the method for front-end use. + + // Clear the matching cache in case things change in the future + if ($method instanceof \craft\commerce\base\ShippingMethod) { + $method->clearMatchingShippingRuleCache(); + } } // Clear the memoized data so next time we watch to match rules, we get fresh data. diff --git a/src/services/ShippingRuleCategories.php b/src/services/ShippingRuleCategories.php index f1a9b96038..2dd3f42609 100644 --- a/src/services/ShippingRuleCategories.php +++ b/src/services/ShippingRuleCategories.php @@ -12,7 +12,6 @@ use craft\commerce\models\ShippingRuleCategory; use craft\commerce\records\ShippingRuleCategory as ShippingRuleCategoryRecord; use craft\db\Query; -use Illuminate\Support\Collection; use Throwable; use yii\base\Component; use yii\db\StaleObjectException; From 3ac6630c8c4ae3ad7304f38b60edb955233a81b8 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 21 May 2026 11:59:43 +0100 Subject: [PATCH 5/8] Tidy --- src/services/ShippingRuleCategories.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/services/ShippingRuleCategories.php b/src/services/ShippingRuleCategories.php index 2dd3f42609..5bc81a630d 100644 --- a/src/services/ShippingRuleCategories.php +++ b/src/services/ShippingRuleCategories.php @@ -24,8 +24,16 @@ */ class ShippingRuleCategories extends Component { + /** + * @var array|null + */ private ?array $_shippingRuleCategories = null; + /** + * Returns shipping rule category data without instantiating the classes for performances purposes + * + * @return array + */ public function getAllShippingRuleCategoriesData(): array { if ($this->_shippingRuleCategories === null) { From a90d1205ba629e3545ce0ab4f54939dbf05a6dd3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 21 May 2026 14:58:03 +0100 Subject: [PATCH 6/8] Ensure memoization is being clear correctly --- src/services/ShippingRuleCategories.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/ShippingRuleCategories.php b/src/services/ShippingRuleCategories.php index 5bc81a630d..eac7c60c49 100644 --- a/src/services/ShippingRuleCategories.php +++ b/src/services/ShippingRuleCategories.php @@ -149,6 +149,8 @@ public function createShippingRuleCategory(ShippingRuleCategory $model, bool $ru // Now that we have a record ID, save it on the model $model->id = $record->id; + $this->_shippingRuleCategories = null; + return true; } @@ -167,7 +169,7 @@ public function deleteShippingRuleCategoryById(int $id): bool if ($record) { // Clear cache if required - unset($this->_shippingRuleCategories[$record->shippingRuleId]); + $this->_shippingRuleCategories = null; return (bool)$record->delete(); } From 08d938b0975ff5671cccb06d02a85da77265872b Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 3 Jun 2026 14:43:11 +0100 Subject: [PATCH 7/8] Changelog update --- CHANGELOG-WIP.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 4ed29cfe99..61e28afedb 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -18,6 +18,7 @@ - Added a `cart/peek-cart` controller action, which returns the existing cart for the current request without creating a new cart or setting cookies — useful for cached pages such as a header cart badge, where `Set-Cookie` responses should be avoided. ([#4263](https://github.com/craftcms/commerce/pull/4263)) - `commerce/cart/load-cart` now returns JSON responses for `application/json` requests, including a `challengeUrl` on failure. +- Improved the performance of shipping method and rule matching. ### Extensibility @@ -42,12 +43,14 @@ - Added `craft\commerce\elements\deletionblockers\SubscriptionCustomersDeletionBlocker`. - Added `craft\commerce\enums\ContainsPurchasablesMatch`. - Added `craft\commerce\events\PaymentCurrencyRateEvent`, allowing plugins to override a payment currency's exchange rate at the point of use. +- Added `craft\commerce\base\ShippingMethod::clearMatchingShippingRuleCache()`. - Added `craft\commerce\services\Carts::getLoadCartUrl()`. - Added `craft\commerce\services\Carts::peekCart()`. - Added `craft\commerce\services\Orders::reassignOrders()`. - Added `craft\commerce\services\Orders::removeCustomerData()`. - Added `craft\commerce\services\PaymentCurrencies::EVENT_DEFINE_PAYMENT_CURRENCY_RATE`. - Added `craft\commerce\services\PaymentCurrencies::getRateFor()`. +- Added `craft\commerce\services\ShippingRuleCategories::getAllShippingRuleCategoriesData()`. - Added `craft\commerce\services\ProductTypes::getCreatableProductTypeIds()`. - Added `craft\commerce\services\ProductTypes::getViewableProductTypeIds()`. - Added `craft\commerce\services\ProductTypes::getViewableProductTypes()`. From 7208845d6cc9cdb2e7e6a45da67686dfa9030597 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 3 Jun 2026 14:43:29 +0100 Subject: [PATCH 8/8] fix-cs --- src/controllers/InventoryController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/controllers/InventoryController.php b/src/controllers/InventoryController.php index 5d7a999cc0..bf4130a5a0 100644 --- a/src/controllers/InventoryController.php +++ b/src/controllers/InventoryController.php @@ -21,14 +21,14 @@ use craft\commerce\models\InventoryLocation; use craft\commerce\Plugin; use craft\commerce\web\assets\inventory\InventoryAsset; +use craft\db\Query; +use craft\db\Table as CraftTable; use craft\enums\MenuItemType; use craft\errors\DeprecationException; use craft\helpers\AdminTable; use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\Html; -use craft\db\Query; -use craft\db\Table as CraftTable; use craft\web\assets\htmx\HtmxAsset; use craft\web\CpScreenResponseBehavior; use yii\base\InvalidConfigException;