timurturdyev / laravel-cart
Shopping cart, wishlist and compare lists for Laravel
Requires
- php: ^8.3
- illuminate/support: ^12.0
Requires (Dev)
- orchestra/testbench: ^10.0
Suggests
- illuminate/database: Required for the database storage driver
README
Laravel Cart
Корзина, закладки и сравнение товаров для Laravel 12+ (PHP 8.3+).
Каждый список (корзина, закладки, сравнение, свои списки) - один и тот же примитив с политикой из конфига. Деньги считает только корзина, и делает это через конвейер классов-корректировок. Без магических строк, без float, без обязательных миграций.
Установка
composer require timurturdyev/laravel-cart
php artisan vendor:publish --tag=cart-config # по желанию
Провайдер подхватывается автоматически через package discovery.
Быстрый старт
use TimurTurdyev\Cart\Facades\Cart; use TimurTurdyev\Cart\Facades\Compare; use TimurTurdyev\Cart\Facades\Wishlist; $line = Cart::add($item, quantity: 2, options: ['size' => 'm']); Cart::setQuantity($line->id, 5); Cart::total(); // Price: ->minor(), ->decimal(), ->format() Wishlist::toggle($item); // первый вызов добавляет, второй убирает Wishlist::has($item); // для кнопки-сердечка Wishlist::moveToCart($item); Compare::add($item); // лимит берется из конфига
$item - любая модель, реализующая Purchasable:
use TimurTurdyev\Cart\Contracts\Purchasable; use TimurTurdyev\Cart\Support\Price; class Chair extends Model implements Purchasable { public function cartId(): string|int { return $this->id; } public function cartName(): string { return $this->name; } public function cartPrice(): Price { return Price::fromMinor($this->price); } }
Без модели строка собирается вручную:
use TimurTurdyev\Cart\Line; Cart::add(Line::of(11, 'Chair', Price::fromDecimal('19.99'), quantity: 2));
Идентичность строки
Id строки = hash(id товара + нормализованные опции). Один товар с разными опциями сам раскладывается по разным строкам, составные id вручную склеивать не нужно:
Cart::add($item, options: ['size' => 'm']); Cart::add($item, options: ['size' => 'l']); // вторая строка Cart::has($item, options: ['size' => 'm']); // true
Данные вне идентичности (зафиксированная картинка, метка времени) живут в meta:
$line = Cart::add($item, meta: ['image' => $url]); $line->meta('image'); $line->model(); // ленивый поиск модели по классу товара
Деньги
Суммы хранятся в целых минорных единицах внутри value-объекта Price. Конверсия в одной точке, округление half-up:
Price::fromMinor(1999); // 19.99 Price::fromDecimal('19.99'); // строка парсится точно Cart::total()->minor(); // 1999 Cart::total()->format(); // "19.99"
Скидки, сборы, доставка
use TimurTurdyev\Cart\Adjusters\PercentageDiscount; use TimurTurdyev\Cart\Adjusters\Shipping; Cart::adjust( new PercentageDiscount('summer', percent: 10), new Shipping(Price::fromMinor(1500)), ); Cart::withoutAdjuster('summer'); Cart::totals()->breakdown(); // subtotal, каждая корректировка, total
В комплекте: PercentageDiscount, FixedDiscount, PercentageFee, Shipping. Корректировки применяются конвейером: каждая видит текущий total, поэтому последовательные скидки компаундятся и порядок важен. Своя корректировка - один класс:
use TimurTurdyev\Cart\Contracts\Adjuster; use TimurTurdyev\Cart\Support\Totals; final readonly class GiftWrap implements Adjuster { public function name(): string { return 'gift-wrap'; } public function adjust(Totals $totals): Totals { return $totals->addFee($this->name(), Price::fromMinor(300)); } public function toArray(): array { return []; } public static function fromArray(array $data): static { return new self(); } }
Между запросами корректировки хранятся парами {class, data} и восстанавливаются с проверкой контракта. unserialize не используется.
Списки
'lists' => [ 'cart' => ['policy' => 'append'], 'wishlist' => ['policy' => 'toggle'], 'compare' => ['policy' => 'toggle', 'limit' => 4], 'viewed' => ['policy' => 'toggle', 'limit' => 20], ],
| политика | поведение |
|---|---|
append |
повторное добавление суммирует количество |
toggle |
повторный add ничего не меняет, toggle() убирает |
limit |
новая строка сверх лимита кидает ListLimitException |
Новый тип списка - строка в конфиге, а не новый класс:
use TimurTurdyev\Cart\CartManager; app(CartManager::class)->list('viewed')->toggle($item); Cart::moveTo('wishlist', $line->id); // отложить на потом Wishlist::moveToCart($item);
Хранилище
По умолчанию session: работает сразу после установки, пустые списки не оставляют записей. Переключение на базу - одна строка конфига:
'storage' => 'database',
php artisan vendor:publish --tag=cart-migrations php artisan migrate
При логине гостевая корзина сливается с корзиной пользователя. Стратегии: sum (количества складываются), keep (строки пользователя важнее), replace (гостевая заменяет).
Свой драйвер подключается снаружи, без правки пакета:
use TimurTurdyev\Cart\Storage\StorageManager; app(StorageManager::class)->extend('redis', fn () => new RedisCartStorage());
События
LineAdded, LineUpdated, LineRemoved, ListCleared. В каждом - имя списка и строка. Отключаются через 'events' => false.
Переход с darryldecode/laravelshoppingcart
Таблица соответствия API - в UPGRADE.md. Сохраненные корзины конвертируются командой:
php artisan cart:import-legacy legacy_carts --owner-column=identifier --data-column=cart_data
Тесты
make check
Лицензия
MIT. См. LICENSE.md.