├── resources ├── manifest.php ├── database │ └── migrations │ │ ├── 2023_01_12_123641_add_cart_item_configuration.php │ │ ├── 2025_05_29_091853_add_parent_id_to_the_cart_items_table.php │ │ ├── 2017_10_28_111947_create_carts_table.php │ │ ├── 2018_10_15_224808_add_cart_state.php │ │ └── 2017_10_29_224033_create_cart_items_table.php └── config │ └── module.php ├── Events ├── CartDeleted.php ├── CartCreated.php ├── CartUpdated.php ├── CartDeleting.php └── BaseCartEvent.php ├── Contracts ├── CartEvent.php ├── CartItem.php ├── CartState.php ├── CartManager.php └── Cart.php ├── Models ├── CartProxy.php ├── CartItemProxy.php ├── CartStateProxy.php ├── CartState.php ├── CartItem.php └── Cart.php ├── Exceptions └── InvalidCartConfigurationException.php ├── Listeners ├── DissociateUserFromCart.php ├── AssignUserToCart.php └── RestoreCurrentUsersLastActiveCart.php ├── README.md ├── LICENSE.md ├── Providers ├── ModuleServiceProvider.php └── EventServiceProvider.php ├── composer.json ├── Facades └── Cart.php ├── Changelog.md └── CartManager.php /resources/manifest.php: -------------------------------------------------------------------------------- 1 | 'Vanilo Cart Module', 7 | 'version' => '5.1.0', 8 | ]; 9 | -------------------------------------------------------------------------------- /Events/CartDeleted.php: -------------------------------------------------------------------------------- 1 | json('configuration')->nullable()->after('price'); 13 | }); 14 | } 15 | 16 | public function down() 17 | { 18 | Schema::table('cart_items', function (Blueprint $table) { 19 | $table->dropColumn('configuration'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /Contracts/CartItem.php: -------------------------------------------------------------------------------- 1 | cart; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/database/migrations/2025_05_29_091853_add_parent_id_to_the_cart_items_table.php: -------------------------------------------------------------------------------- 1 | unsignedInteger('parent_id')->nullable()->after('id'); 14 | }); 15 | } 16 | 17 | public function down(): void 18 | { 19 | Schema::table('cart_items', function (Blueprint $table) { 20 | $table->dropColumn('parent_id'); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /Listeners/DissociateUserFromCart.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->integer('user_id')->unsigned()->nullable(); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::drop('carts'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Listeners/AssignUserToCart.php: -------------------------------------------------------------------------------- 1 | id == $event->user->id) { 30 | return; // Don't associate to the same user again 31 | } 32 | Cart::setUser($event->user); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /resources/database/migrations/2018_10_15_224808_add_cart_state.php: -------------------------------------------------------------------------------- 1 | string('state')->default(CartStateProxy::defaultValue()); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::table('carts', function (Blueprint $table) { 31 | $table->dropColumn('state'); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Listeners/RestoreCurrentUsersLastActiveCart.php: -------------------------------------------------------------------------------- 1 | true, 7 | 'user' => [ 8 | 'model' => null, // Leave null to use config('auth.providers.users.model'): default of v0.1 - v2.0 9 | ], 10 | 'session_key' => 'vanilo_cart', // The session key where the cart id gets saved 11 | 'auto_destroy' => false, // Whether to immediately delete carts with 0 items 12 | 'auto_assign_user' => true, // Whether to automatically set the user_id on new carts (based on Auth::user()) 13 | 'preserve_for_user' => false, // Whether to keep and restore user carts across logins and devices 14 | 'merge_duplicates' => false, // Whether to merge carts if `preserve_for_user` is enabled, user logs in and the session contains another cart 15 | 'items' => [ 16 | 'extra_product_attributes_to_merge' => [], 17 | 'shippable_by_default' => null, 18 | ] 19 | ]; 20 | -------------------------------------------------------------------------------- /resources/database/migrations/2017_10_29_224033_create_cart_items_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->integer('cart_id')->unsigned(); 20 | $table->string('product_type'); 21 | $table->integer('product_id')->unsigned(); 22 | $table->integer('quantity')->unsigned(); 23 | $table->decimal('price', 15, 4); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::drop('cart_items'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vanilo Cart Module 2 | 3 | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/cart/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/cart/actions?query=workflow%3Atests) 4 | [![Packagist version](https://img.shields.io/packagist/v/vanilo/cart.svg?style=flat-square)](https://packagist.org/packages/vanilo/cart) 5 | [![Packagist downloads](https://img.shields.io/packagist/dt/vanilo/cart.svg?style=flat-square)](https://packagist.org/packages/vanilo/cart) 6 | [![MIT Software License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.md) 7 | 8 | This is the standalone cart module of the [Vanilo E-commerce Framework](https://vanilo.io). 9 | 10 | ## Installation 11 | 12 | (As Standalone Component) 13 | 14 | 1. `composer require vanilo/cart` 15 | 2. `php artisan vendor:publish --provider="Konekt\Concord\ConcordServiceProvider"` 16 | 3. Add `Vanilo\Cart\Providers\ModuleServiceProvider::class` to modules in `config/concord.php` 17 | 4. `php artisan migrate` 18 | 19 | ## Usage 20 | 21 | See the [Vanilo Cart Documentation](https://vanilo.io/docs/master/cart) for more details. 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) Attila Fulop 2017 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Providers/ModuleServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(CartManagerContract::class, CartManager::class); 40 | 41 | $this->app->singleton('vanilo.cart', function ($app) { 42 | return $app->make(CartManagerContract::class); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 30 | AssignUserToCart::class, 31 | RestoreCurrentUsersLastActiveCart::class, 32 | ], 33 | Authenticated::class => [ 34 | AssignUserToCart::class, 35 | RestoreCurrentUsersLastActiveCart::class, 36 | ], 37 | Logout::class => [ 38 | DissociateUserFromCart::class 39 | ], 40 | Lockout::class => [ 41 | DissociateUserFromCart::class 42 | ] 43 | ]; 44 | } 45 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanilo/cart", 3 | "description": "Vanilo Cart Module", 4 | "type": "library", 5 | "license": "MIT", 6 | "keywords": ["cart", "ecommerce", "vanilo", "laravel"], 7 | "support": { 8 | "issues": "https://github.com/vanilophp/framework/issues", 9 | "source": "https://github.com/vanilophp/cart" 10 | }, 11 | "minimum-stability": "dev", 12 | "prefer-stable": true, 13 | "authors": [ 14 | { 15 | "name": "Attila Fulop", 16 | "homepage": "https://github.com/fulopattila122" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.3", 21 | "konekt/concord": "^1.15", 22 | "konekt/enum": "^4.2", 23 | "laravel/framework": "^10.48|^11.46.2|^12.38", 24 | "vanilo/contracts": "^5.1", 25 | "vanilo/support": "^5.1" 26 | }, 27 | "require-dev": { 28 | "ext-sqlite3": "*", 29 | "phpunit/phpunit": "^10.0|^11.0", 30 | "orchestra/testbench": "^8.0|^9.0|^10.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { "Vanilo\\Cart\\": "" } 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "aliases": { 38 | "Cart": "Vanilo\\Cart\\Facades\\Cart" 39 | } 40 | }, 41 | "branch-alias": { 42 | "dev-master": "5.1.x-dev" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Models/CartState.php: -------------------------------------------------------------------------------- 1 | value, static::$activeStates); 38 | } 39 | 40 | /** 41 | * @inheritDoc 42 | */ 43 | public static function getActiveStates(): array 44 | { 45 | return static::$activeStates; 46 | } 47 | 48 | protected static function boot() 49 | { 50 | static::$labels = [ 51 | self::ACTIVE => __('Active'), 52 | self::CHECKOUT => __('Checkout'), 53 | self::COMPLETED => __('Completed'), 54 | self::ABANDONDED => __('Abandoned') 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Facades/Cart.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function getRootItems(): Collection; 30 | 31 | /** 32 | * Add an item to the cart (or adds the quantity if the product is already in the cart) 33 | * 34 | * @param Buyable $product Any Buyable object 35 | * @param int|float $qty The quantity to add 36 | * @param array $params Additional parameters, eg. coupon code 37 | * @param bool $forceNewItem If true, a new item will be created even if the same product exists in cart 38 | * 39 | * @return CartItem Returns the item object that has been created (or updated) 40 | */ 41 | public function addItem(Buyable $product, int|float $qty = 1, array $params = [], bool $forceNewItem = false): CartItem; 42 | 43 | public function addSubItem(CartItem $parent, Buyable $product, int|float $qty = 1, array $params = []): CartItem; 44 | 45 | public function removeItem(CartItem $item): void; 46 | 47 | public function removeProduct(Buyable $product): void; 48 | 49 | public function clear(): void; 50 | 51 | public function itemCount(): int; 52 | 53 | public function getState(): ?CartState; 54 | 55 | public function getUser(): ?Authenticatable; 56 | 57 | public function setUser(Authenticatable|int|string|null $user): void; 58 | } 59 | -------------------------------------------------------------------------------- /Models/CartItem.php: -------------------------------------------------------------------------------- 1 | 'json', 49 | ]; 50 | 51 | public function product() 52 | { 53 | return $this->morphTo(); 54 | } 55 | 56 | public function getBuyable(): Buyable 57 | { 58 | return $this->product; 59 | } 60 | 61 | public function isShippable(): ?bool 62 | { 63 | $result = config('vanilo.cart.items.shippable_by_default'); 64 | 65 | return (is_bool($result) || is_null($result)) ? $result : (bool) $result; 66 | } 67 | 68 | public function parent(): BelongsTo 69 | { 70 | return $this->belongsTo(CartItemProxy::modelClass(), 'parent_id'); 71 | } 72 | 73 | public function children(): HasMany 74 | { 75 | return $this->hasMany(CartItemProxy::modelClass(), 'parent_id')->orderBy('id'); 76 | } 77 | 78 | public function hasParent(): bool 79 | { 80 | return null !== $this->parent_id; 81 | } 82 | 83 | public function getParent(): ?CartItemContract 84 | { 85 | return $this->parent; 86 | } 87 | 88 | public function hasChildItems(): bool 89 | { 90 | return $this->children->isNotEmpty(); 91 | } 92 | 93 | public function getChildItems(): Collection 94 | { 95 | return $this->children; 96 | } 97 | 98 | public function getQuantity(): int 99 | { 100 | return (int) $this->quantity; 101 | } 102 | 103 | public function total(): float 104 | { 105 | return $this->price * $this->quantity; 106 | } 107 | 108 | /** 109 | * Property accessor alias to the total() method 110 | * 111 | * @return float 112 | */ 113 | public function getTotalAttribute() 114 | { 115 | return $this->total(); 116 | } 117 | 118 | /** 119 | * Scope to query items of a cart 120 | * 121 | * @param \Illuminate\Database\Eloquent\Builder $query 122 | * @param mixed $cart Cart object or cart id 123 | * 124 | * @return \Illuminate\Database\Eloquent\Builder 125 | */ 126 | public function scopeOfCart($query, $cart) 127 | { 128 | $cartId = is_object($cart) ? $cart->id : $cart; 129 | 130 | return $query->where('cart_id', $cartId); 131 | } 132 | 133 | /** 134 | * Scope to query items by product (Buyable) 135 | * 136 | * @param Builder $query 137 | * @param Buyable $product 138 | * 139 | * @return Builder 140 | */ 141 | public function scopeByProduct($query, Buyable $product) 142 | { 143 | return $query->where([ 144 | ['product_id', '=', $product->getId()], 145 | ['product_type', '=', $product->morphTypeName()] 146 | ]); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Vanilo Cart Module Changelog 2 | 3 | ## 5.x Series 4 | 5 | ## 5.1.0 6 | ##### 2025-12-02 7 | 8 | - Added the `SerializesModels` trait to the `BaseCartEvent` class (thus applies to all events) 9 | - Changed the minimum Laravel version requirements to v10.48, v11.46.2 and v12.38 respectively 10 | 11 | ## 5.0.0 12 | ##### 2025-09-03 13 | 14 | - BC: Added the `addSubItem()`, `getRootItems()` and `getState()` methods to the Cart interface 15 | - BC: Added the `$forceNewItem` (default false) parameter to the `Cart::addItem()` method 16 | - BC: Added the following methods to the CartItem interface: 17 | - `hasParent()` 18 | - `getParent()` 19 | - `hasChildItems()` 20 | - `getChildItems()` 21 | - Dropped PHP 8.2 Support 22 | - Changed the minimum Laravel 10 version to v10.48 23 | - Added Laravel 12 Support 24 | - Added SubItem support to the cart items 25 | - Added the `CartItem::isShippable()` method 26 | - Added the `items.shippable_by_default` configuration option (default: null) which is used to determine whether a cart item is shippable or not by default 27 | 28 | ## 4.x Series 29 | 30 | ## 4.2.0 31 | ##### 2024-12-15 32 | 33 | - Changed `CartItem::hasConfiguration()` to return false on empty arrays as well 34 | - Fixed an error when attempting to remove a product from the cart which is not in the by [xujiongze](https://github.com/xujiongze) in [#188](https://github.com/vanilophp/framework/pull/188) 35 | 36 | ## 4.1.0 37 | ##### 2024-07-11 38 | 39 | - Bump module version to mainline (no change) 40 | 41 | ## 4.0.0 42 | ##### 2024-04-25 43 | 44 | - Dropped PHP 8.0 & PHP 8.1 Support 45 | - Dropped Laravel 9 Support 46 | - Dropped Enum v3 Support 47 | - Added PHP 8.3 Support 48 | - Added Laravel 11 Support 49 | - Added Cart item configuration support (different configurations constitute separate cart items) to the `Cart::addItem()` method 50 | - Changed minimum Laravel version to v10.43 51 | - Changed minimal Enum requirement to v4.2 52 | - Removed the throwing of `CartUpdated` event when destroying a cart (`CartDeleting` and `CartDeleted` remains) 53 | - BC: Changed the `CheckoutSubjectItem` interface into Configurable & Schematized 54 | - BC: Added argument and return types to all `Cart` and `CartManager` interface methods 55 | 56 | ## 3.x Series 57 | 58 | ## 3.8.0 59 | ##### 2023-05-24 60 | 61 | - Bump module version to mainline (no change) 62 | 63 | ## 3.7.0 64 | ##### 2023-04-04 65 | 66 | - Bump module version to mainline (no change) 67 | 68 | ## 3.6.0 69 | ##### 2023-03-07 70 | 71 | - Added Laravel 10 support 72 | - Added the `CartCreated`, `CartUpdated`, `CartDeleted` and `CartDeleting` events 73 | 74 | ## 3.5.0 75 | ##### 2023-02-23 76 | 77 | - Bump module version to mainline (no change) 78 | 79 | ## 3.4.0 80 | ##### 2023-01-25 81 | 82 | - Added `Configurable` to the `CartItem` model (incl. implementing the interface) 83 | 84 | ## 3.3.0 85 | ##### 2023-01-05 86 | 87 | - Added final PHP 8.2 support 88 | 89 | ## 3.2.0 90 | ##### 2022-12-08 91 | 92 | - Added `Cart::fresh()` method to the Cart facade 93 | - Changed minimum Concord version requirement to v1.12 94 | 95 | ## 3.1.0 96 | ##### 2022-11-07 97 | 98 | - Added Enum 4.0 Support 99 | - Added `__call` to `CartManager` that proxies unhandled calls to the underlying cart model 100 | - Changed minimum Laravel requirement to 9.2 101 | - Changed minimum Konekt module requirements to: 102 | - Concord: 1.11 103 | - Enum: 3.1.1 104 | 105 | ## 3.0.1 106 | ##### 2022-05-22 107 | 108 | - Bump module version to mainline (no change) 109 | 110 | ## 3.0.0 111 | ##### 2022-02-28 112 | 113 | - Added Laravel 9 support 114 | - Added PHP 8.1 support 115 | - Dropped PHP 7.4 Support 116 | - Dropped Laravel 6-8 Support 117 | - Removed Admin from "Framework" - it is available as an optional separate package see [vanilo/admin](https://github.com/vanilophp/admin) 118 | - Minimum Laravel version is 8.22.1. [See GHSA-3p32-j457-pg5x](https://github.com/advisories/GHSA-3p32-j457-pg5x) 119 | 120 | 121 | --- 122 | 123 | ## 2.x Series 124 | 125 | ### 2.2.0 126 | ##### 2021-09-11 127 | 128 | - Changed internal CS ruleset from PSR-2 to PSR-12 129 | - Dropped PHP 7.3 support 130 | 131 | ### 2.1.1 132 | ##### 2020-12-31 133 | 134 | - Added PHP 8 support 135 | - Changed CI from Travis to Github 136 | - Only works with Vanilo 2.1+ modules 137 | 138 | ### 2.1.0 139 | ##### 2020-10-27 140 | 141 | - Added configuration option to explicitly define the cart's user model class 142 | - Works with Vanilo 2.0 modules 143 | 144 | ### 2.0.0 145 | ##### 2020-10-11 146 | 147 | - BC: interfaces comply with vanilo/contracts v2 148 | - BC: Upgrade to Enum v3 149 | - Added Laravel 8 support 150 | - Dropped Laravel 5 support 151 | - Dropped PHP 7.2 support 152 | 153 | ## 1.x Series 154 | 155 | ### 1.2.0 156 | ##### 2020-03-29 157 | 158 | - Added Laravel 7 Support 159 | - Added PHP 7.4 support 160 | - Dropped PHP 7.1 support 161 | 162 | ### 1.1.1 163 | ##### 2019-12-21 164 | 165 | - Fixed bug with cart id stuck in session without matching DB entry. 166 | 167 | ### 1.1.0 168 | ##### 2019-11-25 169 | 170 | - Added Laravel 6 Support 171 | - Dropped Laravel 5.4 Support 172 | 173 | ### 1.0.0 174 | ##### 2019-11-11 175 | 176 | - Added protection against missing cart session config key value 177 | - Added merge cart feature on login 178 | 179 | ## 0.5 Series 180 | 181 | ### 0.5.1 182 | ##### 2019-03-17 183 | 184 | - Complete Laravel 5.8 compatibility (likely works with 0.4.0 & 0.5.0 as well) 185 | - PHP 7.0 support has been dropped 186 | 187 | ### 0.5.0 188 | ##### 2019-02-11 189 | 190 | - No change, version has been bumped for v0.5 series 191 | 192 | ## 0.4 Series 193 | 194 | ### 0.4.0 195 | ##### 2018-11-12 196 | 197 | - Possibility to preserve cart for users (across logins) feature 198 | - Laravel 5.7 compatibility 199 | - Tested with PHP 7.3 200 | 201 | ## 0.3 Series 202 | 203 | ### 0.3.0 204 | ##### 2018-08-11 205 | 206 | - Custom product attributes can be passed/configured when adding cart items 207 | - Works with product images 208 | - Test suite improvements for Laravel 5.4 compatibility 209 | - Doc improvements 210 | 211 | ## 0.2 Series 212 | 213 | ### 0.2.0 214 | ##### 2018-02-19 215 | 216 | - Cart user handling works 217 | - Laravel 5.6 compatible 218 | 219 | 220 | ## 0.1 Series 221 | 222 | ### 0.1.0 223 | ##### 2017-12-11 224 | 225 | - 🐣 -> 🛂 -> 🤦 -> 💁 226 | -------------------------------------------------------------------------------- /CartManager.php: -------------------------------------------------------------------------------- 1 | sessionKey = config(self::CONFIG_SESSION_KEY); 46 | 47 | if (empty($this->sessionKey)) { 48 | throw new InvalidCartConfigurationException( 49 | sprintf( 50 | 'Cart session key (`%s`) is empty. Please provide a valid value.', 51 | self::CONFIG_SESSION_KEY 52 | ) 53 | ); 54 | } 55 | } 56 | 57 | public function __call(string $name, array $arguments): mixed 58 | { 59 | if (null !== $this->model()) { 60 | return call_user_func([$this->model(), $name], ...$arguments); 61 | } 62 | 63 | return null; 64 | } 65 | 66 | public function getState(): ?CartState 67 | { 68 | return $this->exists() ? $this->model()->getState() : null; 69 | } 70 | 71 | public function getItems(): Collection 72 | { 73 | return $this->exists() ? $this->model()->getItems() : collect(); 74 | } 75 | 76 | public function itemsTotal(): float 77 | { 78 | return $this->exists() ? $this->model()->itemsTotal() : 0; 79 | } 80 | 81 | public function getRootItems(): Collection 82 | { 83 | return $this->exists() ? $this->model()->getRootItems() : collect(); 84 | } 85 | 86 | public function addItem(Buyable $product, int|float $qty = 1, array $params = [], bool $forceNewItem = false): CartItem 87 | { 88 | $cart = $this->findOrCreateCart(); 89 | 90 | $result = $cart->addItem($product, $qty, $params, $forceNewItem); 91 | $this->triggerCartUpdatedEvent(); 92 | 93 | return $result; 94 | } 95 | 96 | public function addSubItem(CartItem $parent, Buyable $product, float|int $qty = 1, array $params = []): CartItem 97 | { 98 | $params = array_merge($params, ['attributes' => ['parent_id' => $parent->id]]); 99 | 100 | $result = $this->addItem($product, $qty, $params); 101 | if ($parent->relationLoaded('children')) { 102 | $parent->unsetRelation('children'); 103 | } 104 | 105 | return $result; 106 | } 107 | 108 | public function removeItem(CartItem $item): void 109 | { 110 | if ($cart = $this->model()) { 111 | $cart->removeItem($item); 112 | $this->triggerCartUpdatedEvent(); 113 | } 114 | } 115 | 116 | public function removeProduct(Buyable $product): void 117 | { 118 | if ($cart = $this->model()) { 119 | $cart->removeProduct($product); 120 | $this->triggerCartUpdatedEvent(); 121 | } 122 | } 123 | 124 | public function clear(): void 125 | { 126 | if ($cart = $this->model()) { 127 | $cart->clear(); 128 | $this->triggerCartUpdatedEvent(); 129 | } 130 | } 131 | 132 | public function itemCount(): int 133 | { 134 | return $this->exists() ? $this->model()->itemCount() : 0; 135 | } 136 | 137 | public function total(): float 138 | { 139 | return $this->exists() ? $this->model()->total() : 0; 140 | } 141 | 142 | public function exists(): bool 143 | { 144 | return (bool) $this->getCartId() && $this->model(); 145 | } 146 | 147 | public function doesNotExist(): bool 148 | { 149 | return !$this->exists(); 150 | } 151 | 152 | public function model(): ?CartContract 153 | { 154 | $id = $this->getCartId(); 155 | 156 | if ($id && $this->cart) { 157 | return $this->cart; 158 | } elseif ($id) { 159 | $this->cart = CartProxy::find($id); 160 | 161 | return $this->cart; 162 | } 163 | 164 | return null; 165 | } 166 | 167 | public function isEmpty(): bool 168 | { 169 | return 0 == $this->itemCount(); 170 | } 171 | 172 | public function isNotEmpty(): bool 173 | { 174 | return !$this->isEmpty(); 175 | } 176 | 177 | public function destroy(): void 178 | { 179 | if ($this->exists()) { 180 | Event::dispatch(new CartDeleting($this->model())); 181 | } 182 | 183 | // don't call $this->clear() as the triggered CartUpdated event may cause unwanted side effects 184 | $this->model()->clear(); 185 | $this->model()->delete(); 186 | $this->forget(); 187 | 188 | Event::dispatch(new CartDeleted()); 189 | } 190 | 191 | public function create(bool $forceCreateIfExists = false): void 192 | { 193 | if ($this->exists() && !$forceCreateIfExists) { 194 | return; 195 | } 196 | 197 | $this->createCart(); 198 | } 199 | 200 | public function getUser(): ?Authenticatable 201 | { 202 | return $this->exists() ? $this->model()->getUser() : null; 203 | } 204 | 205 | public function setUser(Authenticatable|int|string|null $user): void 206 | { 207 | if ($this->exists()) { 208 | $this->cart->setUser($user); 209 | $this->cart->save(); 210 | $this->cart->load('user'); 211 | } 212 | } 213 | 214 | public function removeUser(): void 215 | { 216 | $this->setUser(null); 217 | } 218 | 219 | public function restoreLastActiveCart($user): void 220 | { 221 | $lastActiveCart = CartProxy::ofUser($user)->actives()->latest()->first(); 222 | 223 | if ($lastActiveCart) { 224 | $this->setCartModel($lastActiveCart); 225 | $this->triggerCartUpdatedEvent(); 226 | } 227 | } 228 | 229 | public function mergeLastActiveCartWithSessionCart($user): void 230 | { 231 | /** @var Cart $lastActiveCart */ 232 | if ($lastActiveCart = CartProxy::ofUser($user)->actives()->latest()->first()) { 233 | /** @var CartItem $item */ 234 | foreach ($lastActiveCart->getItems() as $item) { 235 | $this->addItem($item->getBuyable(), $item->getQuantity()); 236 | } 237 | 238 | $lastActiveCart->delete(); 239 | $this->triggerCartUpdatedEvent(); 240 | } 241 | } 242 | 243 | public function forget(): void 244 | { 245 | $this->cart = null; 246 | session()->forget($this->sessionKey); 247 | } 248 | 249 | /** 250 | * Refreshes the underlying cart model from the database 251 | */ 252 | public function fresh(): self 253 | { 254 | if (null !== $this->cart) { 255 | $this->cart = $this->cart->fresh(); 256 | } 257 | 258 | return $this; 259 | } 260 | 261 | /** 262 | * Returns the model id of the cart for the current session 263 | * or null if it does not exist 264 | * 265 | * @return int|null 266 | */ 267 | protected function getCartId() 268 | { 269 | return session($this->sessionKey); 270 | } 271 | 272 | /** 273 | * Returns the cart model for the current session by either fetching it or creating one 274 | */ 275 | protected function findOrCreateCart(): Cart|CartContract 276 | { 277 | return $this->model() ?: $this->createCart(); 278 | } 279 | 280 | /** 281 | * Creates a new cart model and saves its id in the session 282 | */ 283 | protected function createCart(): Cart|CartContract 284 | { 285 | if (config('vanilo.cart.auto_assign_user') && Auth::check()) { 286 | $attributes = [ 287 | 'user_id' => Auth::user()->id 288 | ]; 289 | } 290 | 291 | $model = $this->setCartModel(CartProxy::create($attributes ?? [])); 292 | 293 | Event::dispatch(new CartCreated($model)); 294 | 295 | return $model; 296 | } 297 | 298 | protected function setCartModel(CartContract $cart): CartContract 299 | { 300 | $this->cart = $cart; 301 | 302 | session([$this->sessionKey => $this->cart->id]); 303 | 304 | return $this->cart; 305 | } 306 | 307 | protected function triggerCartUpdatedEvent(): void 308 | { 309 | if ($this->exists()) { 310 | Event::dispatch(new CartUpdated($this->model())); 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /Models/Cart.php: -------------------------------------------------------------------------------- 1 | 'CartStateProxy@enumClass', 39 | ]; 40 | 41 | public function items(): HasMany 42 | { 43 | return $this->hasMany(CartItemProxy::modelClass(), 'cart_id', 'id')->orderBy('id'); 44 | } 45 | 46 | public function getItems(): Collection 47 | { 48 | return $this->items; 49 | } 50 | 51 | public function itemCount(): int 52 | { 53 | return (int) $this->items->sum('quantity'); 54 | } 55 | 56 | public function getState(): CartStateContract 57 | { 58 | return $this->state; 59 | } 60 | 61 | public function getRootItems(): Collection 62 | { 63 | return $this->getItems()->filter(fn ($item) => !$item->hasParent()); 64 | } 65 | 66 | public function addItem(Buyable $product, int|float $qty = 1, array $params = [], bool $forceNewItem = false): CartItemContract 67 | { 68 | $item = match ($forceNewItem) { 69 | false => $this->resolveCartItem($product, $params), 70 | default => null, 71 | }; 72 | 73 | if (null !== $item) { 74 | $item->quantity += $qty; 75 | $item->save(); 76 | } else { 77 | $item = $this->items()->create( 78 | array_merge( 79 | $this->getDefaultCartItemAttributes($product, $qty), 80 | $this->getExtraProductMergeAttributes($product), 81 | $params['attributes'] ?? [] 82 | ) 83 | ); 84 | } 85 | 86 | $this->load('items'); 87 | 88 | return $item; 89 | } 90 | 91 | public function addSubItem(CartItemContract $parent, Buyable $product, float|int $qty = 1, array $params = []): CartItemContract 92 | { 93 | $params = array_merge($params, ['attributes' => ['parent_id' => $parent->id]]); 94 | 95 | $result = $this->addItem($product, $qty, $params); 96 | if ($parent->relationLoaded('children')) { 97 | $parent->unsetRelation('children'); 98 | } 99 | 100 | return $result; 101 | } 102 | 103 | public function removeItem(CartItemContract $item): void 104 | { 105 | if ($item instanceof Model) { 106 | $item->delete(); 107 | } 108 | 109 | $this->load('items'); 110 | } 111 | 112 | /** 113 | * @inheritDoc 114 | */ 115 | public function removeProduct(Buyable $product): void 116 | { 117 | $item = $this->items()->ofCart($this)->byProduct($product)->first(); 118 | if ($item) { 119 | $this->removeItem($item); 120 | } 121 | } 122 | 123 | /** 124 | * @inheritDoc 125 | */ 126 | public function clear(): void 127 | { 128 | $this->items()->ofCart($this)->delete(); 129 | 130 | $this->load('items'); 131 | } 132 | 133 | public function total(): float 134 | { 135 | return $this->items->sum('total'); 136 | } 137 | 138 | public function itemsTotal(): float 139 | { 140 | return $this->items->sum('total'); 141 | } 142 | 143 | /** 144 | * The cart's user relationship 145 | * 146 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 147 | */ 148 | public function user() 149 | { 150 | $userModel = config('vanilo.cart.user.model') ?: config('auth.providers.users.model'); 151 | 152 | return $this->belongsTo($userModel); 153 | } 154 | 155 | public function getUser(): ?Authenticatable 156 | { 157 | return $this->user; 158 | } 159 | 160 | public function setUser(Authenticatable|int|string|null $user): void 161 | { 162 | if ($user instanceof Authenticatable) { 163 | $user = $user->id; 164 | } 165 | 166 | $this->user_id = $user; 167 | } 168 | 169 | public function scopeActives($query) 170 | { 171 | return $query->whereIn('state', CartStateProxy::getActiveStates()); 172 | } 173 | 174 | public function scopeOfUser($query, $user) 175 | { 176 | return $query->where('user_id', is_object($user) ? $user->id : $user); 177 | } 178 | 179 | protected function resolveCartItem(Buyable $buyable, array $parameters): ?CartItemContract 180 | { 181 | /** @var Collection $existingCartItems */ 182 | $existingCartItems = $this->items()->ofCart($this)->byProduct($buyable)->get(); 183 | if ($existingCartItems->isEmpty()) { 184 | return null; 185 | } 186 | 187 | $itemConfig = Arr::get($parameters, 'attributes.configuration'); 188 | $parentId = Arr::get($parameters, 'attributes.parent_id'); 189 | 190 | if (1 === $existingCartItems->count()) { 191 | $item = $this->items()->ofCart($this)->byProduct($buyable)->first(); 192 | 193 | if ($this->configurationsMatch($item->configuration(), $itemConfig) && $this->parentIdMatches($parentId, $item)) { 194 | return $item; 195 | } 196 | 197 | return null; 198 | } 199 | 200 | foreach ($existingCartItems as $item) { 201 | if ($this->configurationsMatch($item->configuration(), $itemConfig) && $this->parentIdMatches($parentId, $item)) { 202 | return $item; 203 | } 204 | } 205 | 206 | return null; 207 | } 208 | 209 | protected function configurationsMatch(?array $config1, ?array $config2): bool 210 | { 211 | if (empty($config1) && empty($config2)) { 212 | return true; 213 | } elseif (empty($config1) && !empty($config2)) { 214 | return false; 215 | } elseif (empty($config2) && !empty($config1)) { 216 | return false; 217 | } 218 | 219 | if (array_is_list($config1)) { 220 | if (!array_is_list($config2)) { 221 | return false; 222 | } 223 | 224 | return empty(array_diff($config1, $config2)) && empty(array_diff($config2, $config1)); 225 | } else { //Config 1 is associative 226 | if (array_is_list($config2)) { 227 | return false; 228 | } 229 | 230 | return empty(array_diff_assoc($config1, $config2)) && empty(array_diff_assoc($config2, $config1)); 231 | } 232 | 233 | return false; 234 | } 235 | 236 | protected function parentIdMatches(mixed $parentId, CartItemContract $item): bool 237 | { 238 | if (null === $parentId && null === $item->parent_id) { 239 | return true; 240 | } 241 | 242 | return intval($parentId) === $item->parent_id; 243 | } 244 | 245 | /** 246 | * Returns the default attributes of a Buyable for a cart item 247 | * 248 | * @param Buyable $product 249 | * @param integer $qty 250 | * 251 | * @return array 252 | */ 253 | protected function getDefaultCartItemAttributes(Buyable $product, $qty) 254 | { 255 | return [ 256 | 'product_type' => $product->morphTypeName(), 257 | 'product_id' => $product->getId(), 258 | 'quantity' => $qty, 259 | 'price' => $product->getPrice(), 260 | ]; 261 | } 262 | 263 | /** 264 | * Returns the extra product merge attributes for cart_items based on the config 265 | * 266 | * @param Buyable $product 267 | * 268 | * @throws InvalidCartConfigurationException 269 | * 270 | * @return array 271 | */ 272 | protected function getExtraProductMergeAttributes(Buyable $product) 273 | { 274 | $result = []; 275 | $cfg = config(self::EXTRA_PRODUCT_MERGE_ATTRIBUTES_CONFIG_KEY, []); 276 | 277 | if (!is_array($cfg)) { 278 | throw new InvalidCartConfigurationException( 279 | sprintf( 280 | 'The value of `%s` configuration must be an array', 281 | self::EXTRA_PRODUCT_MERGE_ATTRIBUTES_CONFIG_KEY 282 | ) 283 | ); 284 | } 285 | 286 | foreach ($cfg as $attribute) { 287 | if (!is_string($attribute)) { 288 | throw new InvalidCartConfigurationException( 289 | sprintf( 290 | 'The configuration `%s` can only contain an array of strings, `%s` given', 291 | self::EXTRA_PRODUCT_MERGE_ATTRIBUTES_CONFIG_KEY, 292 | gettype($attribute) 293 | ) 294 | ); 295 | } 296 | 297 | $result[$attribute] = $product->{$attribute}; 298 | } 299 | 300 | return $result; 301 | } 302 | } 303 | --------------------------------------------------------------------------------