From 15e747ab44aae294f2b857656a353b8cb86e4829 Mon Sep 17 00:00:00 2001 From: Bob Olde Hampsink Date: Mon, 9 Mar 2026 19:14:19 +0100 Subject: [PATCH 1/5] Add read-only mode to cart retrieval --- src/services/Carts.php | 38 +++++++++++++++++++++++++++---- tests/unit/services/CartsTest.php | 26 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/services/Carts.php b/src/services/Carts.php index f1eae1d2c5..22df5cfde7 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -128,12 +128,33 @@ public function init() * Get the current cart for this session. * * @param bool $forceSave Force the cart. + * @param bool $readOnly Whether to retrieve the cart in read-only mode. * @throws ElementNotFoundException * @throws Exception * @throws Throwable */ - public function getCart(bool $forceSave = false): Order + public function getCart(bool $forceSave = false, bool $readOnly = false): ?Order { + if ($readOnly) { + if (isset($this->_cart)) { + return $this->_cart; + } + + if (!$this->getHasSessionCartNumber()) { + return null; + } + + $request = Craft::$app->getRequest(); + $number = $request->getCookies()->getValue($this->cartCookie['name'], false); + if (!$number) { + return null; + } + + $this->_cartNumber = $number; + $this->_cart = $this->_getCart(false, false); + return $this->_cart; + } + $this->loadCookie(); // TODO: need to see if this should be added to other runtime methods too $this->_getCartCount++; //useful when debugging @@ -212,7 +233,7 @@ public function getCart(bool $forceSave = false): Order /** * Get the current cart for this session. */ - private function _getCart(): ?Order + private function _getCart(bool $forgetInvalidCart = true, bool $checkAnonymousCartSession = true): ?Order { $number = $this->getSessionCartNumber(); /** @var Order|null $cart */ @@ -227,7 +248,9 @@ private function _getCart(): ?Order // If the cart is already completed or trashed, forget the cart and start again. if ($cart && ($cart->isCompleted || $cart->trashed)) { - $this->forgetCart(); + if ($forgetInvalidCart) { + $this->forgetCart(); + } return null; } @@ -237,7 +260,10 @@ private function _getCart(): ?Order // Did an anonymous user provide an email that belonged to a credentialed user? // See CartController::actionUpdate() - $anonymousCartWithCredentialedCustomer = $cart && Craft::$app->getSession()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); + $anonymousCartWithCredentialedCustomer = false; + if ($checkAnonymousCartSession && $cart) { + $anonymousCartWithCredentialedCustomer = Craft::$app->getSession()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); + } if ($cart && $cartCustomer && $cartCustomer->getIsCredentialed() && ( @@ -248,7 +274,9 @@ private function _getCart(): ?Order ($currentUser && $currentUser->id != $cartCustomer->id) ) ) { - $this->forgetCart(); + if ($forgetInvalidCart) { + $this->forgetCart(); + } return null; } diff --git a/tests/unit/services/CartsTest.php b/tests/unit/services/CartsTest.php index ec047400be..68408eb727 100644 --- a/tests/unit/services/CartsTest.php +++ b/tests/unit/services/CartsTest.php @@ -17,6 +17,7 @@ use craftcommercetests\fixtures\CustomerAddressFixture; use craftcommercetests\fixtures\CustomerFixture; use UnitTester; +use yii\web\Cookie; /** * CartsTest. @@ -228,4 +229,29 @@ public function testGetCartSwitchCustomer(): void Craft::$app->getUser()->setIdentity($originalIdentity); Craft::$app->getElements()->deleteElement($cart, true); } + + public function testGetCartReadOnlyModeDoesNotStartCartSession(): void + { + $cartNumber = Plugin::getInstance()->getCarts()->generateCartNumber(); + + $order = new Order(); + $order->number = $cartNumber; + Craft::$app->getElements()->saveElement($order, false); + + $carts = $this->make(Carts::class, [ + 'setSessionCartNumber' => function() { + self::fail('Read-only cart retrieval should not update the cart session.'); + }, + ]); + Plugin::getInstance()->set('carts', $carts); + Craft::$app->getRequest()->getCookies()->add(new Cookie([ + 'name' => $carts->cartCookie['name'], + 'value' => $cartNumber, + ])); + + $cart = Plugin::getInstance()->getCarts()->getCart(readOnly: true); + + self::assertNotNull($cart); + self::assertSame($cartNumber, $cart->number); + } } From 01eb35d613373fb842ee98abaa2b45ed7c62d085 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 16:07:10 +0800 Subject: [PATCH 2/5] Add get static cart --- src/controllers/CartController.php | 15 ++++++++++ src/services/Carts.php | 47 ++++++++++++++++-------------- tests/unit/services/CartsTest.php | 15 ++++++++-- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index e7f93843b6..093a85e039 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -119,6 +119,21 @@ public function actionGetCart(): Response ]); } + /** + * Returns the existing cart for this session without creating one, setting cookies, or touching the session. + * Returns null (as empty cart data) if no cart exists for the current session. + */ + public function actionGetStaticCart(): Response + { + $this->requireAcceptsJson(); + + $cart = Plugin::getInstance()->getCarts()->getStaticCart(); + + return $this->asSuccess(data: [ + $this->_cartVariable => $cart ? $this->cartArray($cart) : null, + ]); + } + /** * Updates the cart by adding purchasables to the cart, updating line items, or updating various cart attributes. * diff --git a/src/services/Carts.php b/src/services/Carts.php index 22df5cfde7..7203aee033 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -128,33 +128,12 @@ public function init() * Get the current cart for this session. * * @param bool $forceSave Force the cart. - * @param bool $readOnly Whether to retrieve the cart in read-only mode. * @throws ElementNotFoundException * @throws Exception * @throws Throwable */ - public function getCart(bool $forceSave = false, bool $readOnly = false): ?Order + public function getCart(bool $forceSave = false): Order { - if ($readOnly) { - if (isset($this->_cart)) { - return $this->_cart; - } - - if (!$this->getHasSessionCartNumber()) { - return null; - } - - $request = Craft::$app->getRequest(); - $number = $request->getCookies()->getValue($this->cartCookie['name'], false); - if (!$number) { - return null; - } - - $this->_cartNumber = $number; - $this->_cart = $this->_getCart(false, false); - return $this->_cart; - } - $this->loadCookie(); // TODO: need to see if this should be added to other runtime methods too $this->_getCartCount++; //useful when debugging @@ -230,6 +209,30 @@ public function getCart(bool $forceSave = false, bool $readOnly = false): ?Order return $this->_cart; } + /** + * Returns the existing cart for this session without creating one, setting cookies, or touching the session. + * Returns null if no cart cookie is present or no matching cart exists. + */ + public function getStaticCart(): ?Order + { + if (isset($this->_cart)) { + return $this->_cart; + } + + if (!$this->getHasSessionCartNumber()) { + return null; + } + + $number = Craft::$app->getRequest()->getCookies()->getValue($this->cartCookie['name'], false); + if (!$number) { + return null; + } + + $this->_cartNumber = $number; + $this->_cart = $this->_getCart(false, false); + return $this->_cart; + } + /** * Get the current cart for this session. */ diff --git a/tests/unit/services/CartsTest.php b/tests/unit/services/CartsTest.php index 68408eb727..b86c3408ce 100644 --- a/tests/unit/services/CartsTest.php +++ b/tests/unit/services/CartsTest.php @@ -230,7 +230,7 @@ public function testGetCartSwitchCustomer(): void Craft::$app->getElements()->deleteElement($cart, true); } - public function testGetCartReadOnlyModeDoesNotStartCartSession(): void + public function testGetStaticCartDoesNotStartCartSession(): void { $cartNumber = Plugin::getInstance()->getCarts()->generateCartNumber(); @@ -240,7 +240,7 @@ public function testGetCartReadOnlyModeDoesNotStartCartSession(): void $carts = $this->make(Carts::class, [ 'setSessionCartNumber' => function() { - self::fail('Read-only cart retrieval should not update the cart session.'); + self::fail('Static cart retrieval should not update the cart session.'); }, ]); Plugin::getInstance()->set('carts', $carts); @@ -249,9 +249,18 @@ public function testGetCartReadOnlyModeDoesNotStartCartSession(): void 'value' => $cartNumber, ])); - $cart = Plugin::getInstance()->getCarts()->getCart(readOnly: true); + $cart = Plugin::getInstance()->getCarts()->getStaticCart(); self::assertNotNull($cart); self::assertSame($cartNumber, $cart->number); + + Craft::$app->getElements()->deleteElement($order, true); + } + + public function testGetStaticCartReturnsNullWithNoCookie(): void + { + $cart = Plugin::getInstance()->getCarts()->getStaticCart(); + + self::assertNull($cart); } } From 6a06e3a8b4ba5d517ddca5902c41fdd6129dcbb2 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 17:03:04 +0800 Subject: [PATCH 3/5] Cleanup --- src/controllers/CartController.php | 2 +- src/services/Carts.php | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 093a85e039..1847ba9206 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -85,7 +85,7 @@ public function behaviors(): array return array_merge(parent::behaviors(), [ 'rateLimiter' => [ 'class' => RateLimiter::class, - 'only' => ['get-cart', 'update-cart', 'load-cart', 'complete'], + 'only' => ['get-cart', 'get-static-cart', 'update-cart', 'load-cart', 'complete'], 'enableRateLimitHeaders' => false, 'user' => function() { // Only apply rate limiting when a cart number is explicitly passed diff --git a/src/services/Carts.php b/src/services/Carts.php index 7203aee033..03a90094cb 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -219,18 +219,20 @@ public function getStaticCart(): ?Order return $this->_cart; } - if (!$this->getHasSessionCartNumber()) { + // check directly, calling getHasSessionCartNumber() would cause the cart number to be loaded from the cookie and set in the session, which we don't want to do here. + if ($this->_cartNumber === false) { return null; } - $number = Craft::$app->getRequest()->getCookies()->getValue($this->cartCookie['name'], false); - if (!$number) { + if (!$this->_cartNumber) { + $this->_cartNumber = Craft::$app->getRequest()->getCookies()->getValue($this->cartCookie['name'], false) ?: null; + } + + if (!$this->_cartNumber) { return null; } - $this->_cartNumber = $number; - $this->_cart = $this->_getCart(false, false); - return $this->_cart; + return Order::find()->number($this->_cartNumber)->isCompleted(false)->one(); } /** From 0372e5a6b9fa4d1ff587569403026948bc0cff35 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 20:59:25 +0800 Subject: [PATCH 4/5] Fix tests --- src/services/Carts.php | 1 - tests/unit/services/CartsTest.php | 49 ++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/services/Carts.php b/src/services/Carts.php index 03a90094cb..953b294409 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -219,7 +219,6 @@ public function getStaticCart(): ?Order return $this->_cart; } - // check directly, calling getHasSessionCartNumber() would cause the cart number to be loaded from the cookie and set in the session, which we don't want to do here. if ($this->_cartNumber === false) { return null; } diff --git a/tests/unit/services/CartsTest.php b/tests/unit/services/CartsTest.php index b86c3408ce..1eef7422b3 100644 --- a/tests/unit/services/CartsTest.php +++ b/tests/unit/services/CartsTest.php @@ -17,7 +17,6 @@ use craftcommercetests\fixtures\CustomerAddressFixture; use craftcommercetests\fixtures\CustomerFixture; use UnitTester; -use yii\web\Cookie; /** * CartsTest. @@ -232,7 +231,9 @@ public function testGetCartSwitchCustomer(): void public function testGetStaticCartDoesNotStartCartSession(): void { - $cartNumber = Plugin::getInstance()->getCarts()->generateCartNumber(); + $originalCarts = Plugin::getInstance()->getCarts(); + $cartNumber = $originalCarts->generateCartNumber(); + $cookieName = $originalCarts->cartCookie['name']; $order = new Order(); $order->number = $cartNumber; @@ -243,24 +244,50 @@ public function testGetStaticCartDoesNotStartCartSession(): void self::fail('Static cart retrieval should not update the cart session.'); }, ]); + $carts->cartCookie = ['name' => $cookieName]; Plugin::getInstance()->set('carts', $carts); - Craft::$app->getRequest()->getCookies()->add(new Cookie([ - 'name' => $carts->cartCookie['name'], + + $requestCookies = new \yii\web\CookieCollection(); + $requestCookies->add(new \yii\web\Cookie([ + 'name' => $cookieName, 'value' => $cartNumber, ])); + $originalRequest = Craft::$app->getRequest(); + $requestMock = $this->make(Request::class, [ + 'getCookies' => $requestCookies, + ]); + Craft::$app->set('request', $requestMock); - $cart = Plugin::getInstance()->getCarts()->getStaticCart(); - - self::assertNotNull($cart); - self::assertSame($cartNumber, $cart->number); + try { + $cart = Plugin::getInstance()->getCarts()->getStaticCart(); - Craft::$app->getElements()->deleteElement($order, true); + self::assertNotNull($cart); + self::assertSame($cartNumber, $cart->number); + } finally { + Craft::$app->set('request', $originalRequest); + Craft::$app->getElements()->deleteElement($order, true); + } } public function testGetStaticCartReturnsNullWithNoCookie(): void { - $cart = Plugin::getInstance()->getCarts()->getStaticCart(); + $cookieName = Plugin::getInstance()->getCarts()->cartCookie['name']; + + $carts = $this->make(Carts::class); + $carts->cartCookie = ['name' => $cookieName]; + Plugin::getInstance()->set('carts', $carts); + + $originalRequest = Craft::$app->getRequest(); + $requestMock = $this->make(Request::class, [ + 'getCookies' => new \yii\web\CookieCollection(), + ]); + Craft::$app->set('request', $requestMock); - self::assertNull($cart); + try { + $cart = Plugin::getInstance()->getCarts()->getStaticCart(); + self::assertNull($cart); + } finally { + Craft::$app->set('request', $originalRequest); + } } } From 9b50ef05532879609c950d7009c252d8ef66e0f6 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 3 Jun 2026 14:01:47 +0800 Subject: [PATCH 5/5] Peek cart --- CHANGELOG-WIP.md | 3 ++ src/controllers/CartController.php | 6 ++-- src/services/Carts.php | 49 +++++++++++++++++++++--------- tests/unit/services/CartsTest.php | 10 +++--- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 00f8c378e9..9c258c71a4 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -31,12 +31,15 @@ ## Development - Product permissions have been refined into separate "View", "Create", "Save", and "Delete" permissions. +- 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)) ## Extensibility - Added `craft\commerce\services\ProductTypes::getViewableProductTypes()`. - Added `craft\commerce\services\ProductTypes::getViewableProductTypeIds()`. - Added `craft\commerce\services\ProductTypes::getCreatableProductTypeIds()`. +- Added `craft\commerce\services\Carts::peekCart()`. +- Added `craft\commerce\controllers\CartController::actionPeekCart()`. - Deprecated `craft\commerce\services\ProductTypes::hasPermission()`. Use `$user->can()` directly instead. - Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypes()`. Use `getViewableProductTypes()` instead. - Deprecated `craft\commerce\services\ProductTypes::getEditableProductTypeIds()`. Use `getViewableProductTypeIds()` instead. diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 1847ba9206..92115278b0 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -85,7 +85,7 @@ public function behaviors(): array return array_merge(parent::behaviors(), [ 'rateLimiter' => [ 'class' => RateLimiter::class, - 'only' => ['get-cart', 'get-static-cart', 'update-cart', 'load-cart', 'complete'], + 'only' => ['get-cart', 'peek-cart', 'update-cart', 'load-cart', 'complete'], 'enableRateLimitHeaders' => false, 'user' => function() { // Only apply rate limiting when a cart number is explicitly passed @@ -123,11 +123,11 @@ public function actionGetCart(): Response * Returns the existing cart for this session without creating one, setting cookies, or touching the session. * Returns null (as empty cart data) if no cart exists for the current session. */ - public function actionGetStaticCart(): Response + public function actionPeekCart(): Response { $this->requireAcceptsJson(); - $cart = Plugin::getInstance()->getCarts()->getStaticCart(); + $cart = Plugin::getInstance()->getCarts()->peekCart(); return $this->asSuccess(data: [ $this->_cartVariable => $cart ? $this->cartArray($cart) : null, diff --git a/src/services/Carts.php b/src/services/Carts.php index 953b294409..7d9024585b 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -212,8 +212,10 @@ public function getCart(bool $forceSave = false): Order /** * Returns the existing cart for this session without creating one, setting cookies, or touching the session. * Returns null if no cart cookie is present or no matching cart exists. + * + * @since 5.7.0 */ - public function getStaticCart(): ?Order + public function peekCart(): ?Order { if (isset($this->_cart)) { return $this->_cart; @@ -224,20 +226,44 @@ public function getStaticCart(): ?Order } if (!$this->_cartNumber) { - $this->_cartNumber = Craft::$app->getRequest()->getCookies()->getValue($this->cartCookie['name'], false) ?: null; + $cookieNumber = Craft::$app->getRequest()->getCookies()->getValue($this->cartCookie['name'], false); + if (!$cookieNumber) { + return null; + } + $this->_cartNumber = $cookieNumber; } - if (!$this->_cartNumber) { + /** @var Order|null $cart */ + $cart = Order::find() + ->number($this->_cartNumber) + ->storeId(Plugin::getInstance()->getStores()->getCurrentStore()->id) + ->isCompleted(false) + ->trashed(false) + ->one(); + + if (!$cart) { return null; } - return Order::find()->number($this->_cartNumber)->isCompleted(false)->one(); + // Don't return a cart that belongs to a credentialed user who isn't currently logged in + // as that user. Mirrors the privacy check in _getCart(), but without forgetting the cart + // (which would set a Set-Cookie header and defeat the purpose of this method). + $cartCustomer = $cart->getCustomer(); + if ($cartCustomer && $cartCustomer->getIsCredentialed()) { + $currentUser = Craft::$app->getUser()->getIdentity(); + if (!$currentUser || $currentUser->id != $cartCustomer->id) { + return null; + } + } + + $this->_cart = $cart; + return $this->_cart; } /** * Get the current cart for this session. */ - private function _getCart(bool $forgetInvalidCart = true, bool $checkAnonymousCartSession = true): ?Order + private function _getCart(): ?Order { $number = $this->getSessionCartNumber(); /** @var Order|null $cart */ @@ -252,9 +278,7 @@ private function _getCart(bool $forgetInvalidCart = true, bool $checkAnonymousCa // If the cart is already completed or trashed, forget the cart and start again. if ($cart && ($cart->isCompleted || $cart->trashed)) { - if ($forgetInvalidCart) { - $this->forgetCart(); - } + $this->forgetCart(); return null; } @@ -264,10 +288,7 @@ private function _getCart(bool $forgetInvalidCart = true, bool $checkAnonymousCa // Did an anonymous user provide an email that belonged to a credentialed user? // See CartController::actionUpdate() - $anonymousCartWithCredentialedCustomer = false; - if ($checkAnonymousCartSession && $cart) { - $anonymousCartWithCredentialedCustomer = Craft::$app->getSession()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); - } + $anonymousCartWithCredentialedCustomer = $cart && Craft::$app->getSession()->get('commerce:anonymousCartWithCredentialedCustomer:' . $cart->number, false); if ($cart && $cartCustomer && $cartCustomer->getIsCredentialed() && ( @@ -278,9 +299,7 @@ private function _getCart(bool $forgetInvalidCart = true, bool $checkAnonymousCa ($currentUser && $currentUser->id != $cartCustomer->id) ) ) { - if ($forgetInvalidCart) { - $this->forgetCart(); - } + $this->forgetCart(); return null; } diff --git a/tests/unit/services/CartsTest.php b/tests/unit/services/CartsTest.php index 1eef7422b3..413f362db5 100644 --- a/tests/unit/services/CartsTest.php +++ b/tests/unit/services/CartsTest.php @@ -229,7 +229,7 @@ public function testGetCartSwitchCustomer(): void Craft::$app->getElements()->deleteElement($cart, true); } - public function testGetStaticCartDoesNotStartCartSession(): void + public function testPeekCartDoesNotStartCartSession(): void { $originalCarts = Plugin::getInstance()->getCarts(); $cartNumber = $originalCarts->generateCartNumber(); @@ -241,7 +241,7 @@ public function testGetStaticCartDoesNotStartCartSession(): void $carts = $this->make(Carts::class, [ 'setSessionCartNumber' => function() { - self::fail('Static cart retrieval should not update the cart session.'); + self::fail('Peek cart retrieval should not update the cart session.'); }, ]); $carts->cartCookie = ['name' => $cookieName]; @@ -259,7 +259,7 @@ public function testGetStaticCartDoesNotStartCartSession(): void Craft::$app->set('request', $requestMock); try { - $cart = Plugin::getInstance()->getCarts()->getStaticCart(); + $cart = Plugin::getInstance()->getCarts()->peekCart(); self::assertNotNull($cart); self::assertSame($cartNumber, $cart->number); @@ -269,7 +269,7 @@ public function testGetStaticCartDoesNotStartCartSession(): void } } - public function testGetStaticCartReturnsNullWithNoCookie(): void + public function testPeekCartReturnsNullWithNoCookie(): void { $cookieName = Plugin::getInstance()->getCarts()->cartCookie['name']; @@ -284,7 +284,7 @@ public function testGetStaticCartReturnsNullWithNoCookie(): void Craft::$app->set('request', $requestMock); try { - $cart = Plugin::getInstance()->getCarts()->getStaticCart(); + $cart = Plugin::getInstance()->getCarts()->peekCart(); self::assertNull($cart); } finally { Craft::$app->set('request', $originalRequest);