From 93606d9189b47e71ae9147a6633b45af1fa21823 Mon Sep 17 00:00:00 2001 From: wakqasahmed Date: Wed, 26 Aug 2026 15:35:11 +0200 Subject: [PATCH] Reload nested cart relations after refresh to avoid a lazy load (#2222) Cart::add(), remove(), updateLine() and the other cart mutators all called $this->refresh() before recalculating. Eloquent's refresh() only reloads relations that are already loaded on the model, and only one level deep, so a nested relation configured in lunar.cart.eager_load (e.g. lines.purchasable.prices) ended up dropped after the refresh and got lazy-loaded the next time the cart was calculated. With lazy loading disabled that throws a LazyLoadingViolationException, which is what happens when a second product is added to a cart in the same request. Added Cart::refreshForCalculation(), which refreshes the cart and then reapplies the full eager_load config so the nested relations survive, and swapped it in everywhere the old refresh()->recalculate() pattern was used. Added a test asserting the nested relations survive a refresh, and adapted the add-to-cart scenario from #2221's failing test into a regression test for the cart mutators themselves. --- packages/core/src/Models/Cart.php | 41 +++++++++++----- tests/core/Unit/Models/CartTest.php | 73 +++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/packages/core/src/Models/Cart.php b/packages/core/src/Models/Cart.php index f9bcee2d66..7b9cf8e61a 100644 --- a/packages/core/src/Models/Cart.php +++ b/packages/core/src/Models/Cart.php @@ -398,6 +398,23 @@ public function recalculate(): Cart return $this->calculate(force: true); } + /** + * Refresh the cart and reload the relations used during calculation. + * + * Eloquent's refresh() only reloads relations that are already loaded, + * and only one level deep, so nested relations configured in + * `lunar.cart.eager_load` (e.g. `lines.purchasable.prices.currency`) can + * end up only partially loaded after a refresh. Reloading them + * explicitly avoids a lazy load being triggered the next time the cart + * is calculated. + */ + public function refreshForCalculation(): Cart + { + return $this->refresh()->load( + config('lunar.cart.eager_load', []) + ); + } + public function isCalculated(): bool { return ! blank($this->total) && $this->lines->every( @@ -423,7 +440,7 @@ public function add(Purchasable $purchasable, int $quantity = 1, array $meta = [ return app( config('lunar.cart.actions.add_to_cart', AddOrUpdatePurchasable::class) )->execute($this, $purchasable, $quantity, $meta) - ->then(fn () => $refresh ? $this->refresh()->recalculate() : $this); + ->then(fn () => $refresh ? $this->refreshForCalculation()->recalculate() : $this); } public function addLines(iterable $lines): Cart @@ -439,7 +456,7 @@ public function addLines(iterable $lines): Cart }); }); - return $this->refresh()->recalculate(); + return $this->refreshForCalculation()->recalculate(); } public function remove(int $cartLineId, bool $refresh = true): Cart @@ -454,7 +471,7 @@ public function remove(int $cartLineId, bool $refresh = true): Cart return app( config('lunar.cart.actions.remove_from_cart', RemovePurchasable::class) )->execute($this, $cartLineId) - ->then(fn () => $refresh ? $this->refresh()->recalculate() : $this); + ->then(fn () => $refresh ? $this->refreshForCalculation()->recalculate() : $this); } /** @@ -474,7 +491,7 @@ public function updateLine(int $cartLineId, int $quantity, ?array $meta = null, return app( config('lunar.cart.actions.update_cart_line', UpdateCartLine::class) )->execute($cartLineId, $quantity, $meta) - ->then(fn () => $refresh ? $this->refresh()->recalculate() : $this); + ->then(fn () => $refresh ? $this->refreshForCalculation()->recalculate() : $this); } public function updateLines(Collection $lines): Cart @@ -490,14 +507,14 @@ public function updateLines(Collection $lines): Cart }); }); - return $this->refresh()->recalculate(); + return $this->refreshForCalculation()->recalculate(); } public function clear(): Cart { $this->lines()->delete(); - return $this->refresh()->recalculate(); + return $this->refreshForCalculation()->recalculate(); } /** @@ -518,7 +535,7 @@ public function associate(LunarUser $user, string $policy = 'merge', bool $refre return app( config('lunar.cart.actions.associate_user', AssociateUser::class) )->execute($this, $user, $policy) - ->then(fn () => $refresh ? $this->refresh()->recalculate() : $this); + ->then(fn () => $refresh ? $this->refreshForCalculation()->recalculate() : $this); } public function setCustomer(Customer $customer): Cart @@ -533,7 +550,7 @@ public function setCustomer(Customer $customer): Cart $this->customer()->associate($customer)->save(); - return $this->refresh()->recalculate(); + return $this->refreshForCalculation()->recalculate(); } public function addAddress(array|Addressable $address, string $type, bool $refresh = true): Cart @@ -549,7 +566,7 @@ public function addAddress(array|Addressable $address, string $type, bool $refre return app( config('lunar.cart.actions.add_address', AddAddress::class) )->execute($this, $address, $type) - ->then(fn () => $refresh ? $this->refresh()->recalculate() : $this); + ->then(fn () => $refresh ? $this->refreshForCalculation()->recalculate() : $this); } public function setShippingAddress(array|Addressable $address, bool $clearTaxZone = true): Cart @@ -578,7 +595,7 @@ public function setShippingOption(ShippingOption $option, bool $refresh = true): return app( config('lunar.cart.actions.set_shipping_option', SetShippingOption::class) )->execute($this, $option) - ->then(fn () => $refresh ? $this->refresh()->recalculate() : $this); + ->then(fn () => $refresh ? $this->refreshForCalculation()->recalculate() : $this); } public function getShippingOption(): ?ShippingOption @@ -597,7 +614,7 @@ public function createOrder( bool $allowMultipleOrders = false, ?int $orderIdToUpdate = null ): Order { - $cart = $this->refresh()->recalculate(); + $cart = $this->refreshForCalculation()->recalculate(); foreach (config('lunar.cart.validators.order_create', [ ValidateCartForOrderCreation::class, @@ -707,6 +724,6 @@ public function setTaxZone(?TaxZoneContract $taxZone, bool $refresh = true): Car $this->save(); - return $this->refresh()->recalculate(); + return $this->refreshForCalculation()->recalculate(); } } diff --git a/tests/core/Unit/Models/CartTest.php b/tests/core/Unit/Models/CartTest.php index 3c79ecd073..b038768c45 100644 --- a/tests/core/Unit/Models/CartTest.php +++ b/tests/core/Unit/Models/CartTest.php @@ -1360,3 +1360,76 @@ expect($cart->load('lines')->lines->pluck('id')->all()) ->toBe($expectedOrder); }); + +test('refreshing the cart for calculation reloads nested relations dropped by a plain refresh', function () { + // Regression test for #2222: adding a second product to a cart in the + // same request used to throw a LazyLoadingException. Cart::add(), + // Cart::remove() etc. all called $this->refresh() before recalculating, + // but Eloquent's refresh() only reloads relations that are already + // loaded, and only one level deep. So a nested relation configured in + // lunar.cart.eager_load, such as lines.purchasable.prices, ended up + // dropped after a refresh and got lazy-loaded the next time the cart + // was calculated. + $currency = Currency::factory()->create(); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + ]); + + $purchasable = ProductVariant::factory()->create(['stock' => 1]); + + Price::factory()->create([ + 'price' => 100, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + + $cart->lines()->create([ + 'purchasable_type' => $purchasable->getMorphClass(), + 'purchasable_id' => $purchasable->id, + 'quantity' => 1, + ]); + + $cart->load(config('lunar.cart.eager_load')); + + // A plain Eloquent refresh() drops the nested relations. + $cart->refresh(); + expect($cart->lines->first()->relationLoaded('purchasable'))->toBeFalse(); + + // refreshForCalculation() reapplies the full eager_load config, so the + // nested relations survive the refresh. + $refreshed = $cart->refreshForCalculation(); + $line = $refreshed->lines->first(); + + expect($line->relationLoaded('purchasable'))->toBeTrue() + ->and($line->purchasable->relationLoaded('prices'))->toBeTrue() + ->and($line->purchasable->prices->first()->relationLoaded('currency'))->toBeTrue(); +}); + +test('can add multiple purchasables to a cart without triggering lazy loading', function () { + $currency = Currency::factory()->create(); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + ]); + + $purchasableA = ProductVariant::factory()->create(['stock' => 1]); + $purchasableB = ProductVariant::factory()->create(['stock' => 1]); + + foreach ([$purchasableA, $purchasableB] as $purchasable) { + Price::factory()->create([ + 'price' => 100, + 'min_quantity' => 1, + 'currency_id' => $currency->id, + 'priceable_type' => $purchasable->getMorphClass(), + 'priceable_id' => $purchasable->id, + ]); + } + + $cart->add($purchasableA, 1); + $cart->add($purchasableB, 1); + + expect($cart->lines)->toHaveCount(2); +})->throwsNoExceptions();