timurturdyev / simple-cart
A simple cart, wishlist and compare lists for Laravel - one core, zero migrations
Requires
- php: ^8.3
- illuminate/auth: ^12.0
- illuminate/support: ^12.0
Requires (Dev)
- orchestra/testbench: ^10.0
Suggests
- illuminate/database: Required for the database storage driver
README
Simple Cart
Simple by design, not by capability. Корзина, закладки и сравнение товаров для Laravel 12+ (PHP 8.3+).
Каждый список (корзина, закладки, сравнение, свои списки) - один и тот же примитив с политикой из конфига. Деньги считает только корзина, и делает это через конвейер классов-корректировок. Без магических строк, без float, без обязательных миграций.
Установка
composer require timurturdyev/simple-cart
php artisan vendor:publish --tag=simple-cart-config # по желанию
Провайдер подхватывается автоматически через package discovery.
Быстрый старт
use TimurTurdyev\SimpleCart\Facades\Cart; use TimurTurdyev\SimpleCart\Facades\Compare; use TimurTurdyev\SimpleCart\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\SimpleCart\Contracts\Purchasable; use TimurTurdyev\SimpleCart\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\SimpleCart\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(); // ленивый поиск модели по классу товара Cart::models(); // все модели списка одним запросом на тип
Результат model() запоминается на время жизни строки, а models() греет этот кэш сразу для всего списка.
Деньги
Суммы хранятся в целых минорных единицах внутри value-объекта Price. Конверсия в одной точке, округление half-up:
Price::fromMinor(1999); // 19.99 Price::fromDecimal('19.99'); // строка парсится точно Cart::total()->minor(); // 1999 Cart::total()->format(); // "19.99"
Скидки, сборы, доставка
use TimurTurdyev\SimpleCart\Adjusters\PercentageDiscount; use TimurTurdyev\SimpleCart\Adjusters\Shipping; Cart::adjust( new PercentageDiscount('summer', percent: 10), new Shipping(Price::fromMinor(1500)), ); Cart::removeAdjuster('summer'); Cart::totals()->breakdown(); // subtotal, каждая корректировка, total
В комплекте: PercentageDiscount, FixedDiscount, PercentageFee, Shipping. Корректировки применяются конвейером: каждая видит текущий total, поэтому последовательные скидки компаундятся и порядок важен. Своя корректировка - один класс:
use TimurTurdyev\SimpleCart\Contracts\Adjuster; use TimurTurdyev\SimpleCart\Support\Totals; final readonly class GiftWrap implements Adjuster { public function __construct(private Price $amount) { } public function name(): string { return 'gift-wrap'; } public function adjust(Totals $totals): Totals { return $totals->addFee($this->name(), $this->amount); } public function toArray(): array { return ['amount' => $this->amount->minor()]; } public static function fromArray(array $data): static { return new self(Price::fromMinor($data['amount'])); } }
Между запросами корректировки хранятся парами {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\SimpleCart\CartManager; app(CartManager::class)->list('viewed')->toggle($item); Cart::moveTo('wishlist', $line->id); // отложить на потом Wishlist::moveToCart($item);
Хранилище
По умолчанию session: работает сразу после установки, пустые списки не оставляют записей. Переключение на базу - одна строка конфига:
'storage' => 'database',
php artisan vendor:publish --tag=simple-cart-migrations php artisan migrate
При логине гостевая корзина сливается с корзиной пользователя. Стратегии: sum (количества складываются), keep (строки пользователя важнее), replace (гостевая заменяет). Сливать умеют драйверы с SupportsOwnerMerge (database умеет из коробки), с остальными листенер просто пропускает логин.
Свой драйвер подключается снаружи, без правки пакета:
use TimurTurdyev\SimpleCart\Storage\StorageManager; app(StorageManager::class)->extend('redis', fn () => new RedisCartStorage());
Под нагрузкой добавьте сквозной кэш (секция cache конфига): ключ на корзину, чтение с прогретым ключом до базы не доходит. Работает с любым драйвером, кастомным тоже.
Идентичность корзины
За владельца корзины отвечает слой CartIdentity. По умолчанию сессия. Нужна гостевая корзина дольше сессии - ставьте cookie-драйвер:
'identity' => [ 'driver' => 'cookie', 'cookie' => [ 'name' => env('SIMPLE_CART_COOKIE', 'simple_cart_id'), 'ttl_minutes' => 60 * 24 * 30, ], ],
Cookie ставится лениво, при первой реальной записи: гость с пустой корзиной cookie не получит. Залогиненного пользователя определяет auth id. Шифрование - штатный EncryptCookies приложения.
Свой вариант подключается через IdentityManager::extend(), как у хранилища.
События
LineAdded, LineUpdated, LineRemoved, ListCleared. В каждом - имя списка и строка. Отключаются через 'events' => false.
Оформление заказа
Заказ - зона приложения: модель, статусы, оплата и нумерация живут на его стороне. Пакет отдает снапшот корзины и очищает ее:
use Illuminate\Support\Facades\DB; use TimurTurdyev\SimpleCart\Facades\Cart; use TimurTurdyev\SimpleCart\Line; use TimurTurdyev\SimpleCart\Support\Price; $order = DB::transaction(function () { $breakdown = Cart::totals()->breakdown(); $order = Order::create([ 'lines' => Cart::items()->map(fn (Line $line): array => $line->toArray())->values()->all(), 'subtotal' => $breakdown['subtotal']->minor(), 'adjustments' => array_map(fn (Price $amount): int => $amount->minor(), $breakdown['adjustments']), 'total' => $breakdown['total']->minor(), ]); Cart::clear(); return $order; });
Цены зафиксированы в момент add() и лежат в минорных единицах - подорожание товара между добавлением и оформлением на заказ не влияет. Скидки в снапшоте отрицательные, поэтому subtotal + adjustments = total. clear() шлет ListCleared - удобная точка для аналитики. Сценарий закреплен тестом tests/CheckoutRecipeTest.php.
Аналитика и брошенные корзины
Таблица simple_cart_lists открыта для чтения: колонки owner, list, payload и таймстампы, формат меняет разве что мажорная версия.
Брошенные корзины админка находит SQL-запросом по updated_at. Внутрь payload пускают json-функции БД и модель TimurTurdyev\SimpleCart\Storage\CartRecord. Цены в минорных единицах, курсов валют тут нет.
Переход с darryldecode/laravelshoppingcart
Таблица соответствия API и рецепт переноса данных - в UPGRADE.md. Встроенного импорта нет: старые корзины переносит одноразовый скрипт приложения через forOwner($ownerId)->write(...), id владельцев не меняются, живые cookie продолжают находить свои корзины.
Тесты
make check
Лицензия
MIT. См. LICENSE.md.