E-commerce магазини часто потребують більше, ніж просту знижку у відсотках. Інтернет-магазин може запускати сезонний розпродаж, SaaS-продукт - роздавати промокоди на запуск, а знижка в 10% може мати максимальний ліміт, прив'язуватися до мінімальної суми замовлення, обмежуватися кількістю використань на клієнта та мати термін дії. Пакет Laravel Discount від Milwad Khosravi зберігає всю цю інформацію в Eloquent-моделях і опрацьовує через єдиний фасад.
Основні можливості
Пакет охоплює наступний функціонал:
- Два типи знижок:
DiscountType::Percentage (відсоткові) та DiscountType::Fixed (фіксовані) з опціональним обмеженням max_discount_amount
- Промокоди: знижки з полем
code працюють як купони, знижки без коду застосовуються автоматично
- Часові вікна: поля
starts_at та expires_at разом з query scope valid()
- Ліміти використання: загальний
usage_limit та usage_limit_per_user для контролю використання на клієнта
- Підтримка гостей: передавайте ID сесії для контролю лімітів неавторизованих користувачів
- Правила стекування: позначайте знижки як
is_stackable, і пакет визначить найвигіднішу комбінацію
- Прив'язка до моделей: трейт
HasDiscounts дозволяє прикріплювати знижки до будь-якої Eloquent-моделі
- Інтеграція з кошиком: сервіс
CartDiscount застосовує коди до загальної суми Laravel Cart або до окремого товару
Відсоткові та фіксовані знижки
Знижка - це стандартна Eloquent-модель, тому створюється як звичайний запис:
use Binafy\LaravelDiscount\Enums\DiscountType;
use Binafy\LaravelDiscount\Models\Discount;
$discount = Discount::query()->create([
'name' => 'Summer Sale',
'type' => DiscountType::Percentage,
'value' => 20,
]);
Застосування відбувається через фасад LaravelDiscount, який валідує знижку перед розрахунками і повертає об'єкт DiscountResult:
use Binafy\LaravelDiscount\Facades\LaravelDiscount;
$result = LaravelDiscount::apply($discount, 200);
$result->originalAmount; // 200.0
$result->discountAmount; // 40.0
$result->payableAmount(); // 160.0
Об'єкт DiscountResult містить застосовані знижки, оригінальну суму та розраховане значення знижки. Метод payableAmount() віднімає знижку від оригінальної суми, обмежуючи результат мінімумом нуля, щоб фіксовані знижки ніколи не давали від'ємних сум.
Знижка будь-якого типу може мати стелю через max_discount_amount, що охоплює випадок "20% знижки, максимум $100", який інакше доводиться жорстко кодувати в контролері:
$discount = Discount::query()->create([
'code' => 'SAVE20',
'type' => DiscountType::Percentage,
'value' => 20,
'max_discount_amount' => 100,
]);
LaravelDiscount::apply($discount, 300)->discountAmount; // 60.0
LaravelDiscount::apply($discount, 1000)->discountAmount; // 100.0
Промокоди, терміни дії та ліміти використання
Встановлення поля code перетворює знижку на promotional купон. Метод applyCode() валідує код і викидає DiscountNotFoundException, якщо відповідності не знайдено:
$result = LaravelDiscount::applyCode('WELCOME10', 200, $user);
Для кампаній, де кожному клієнту потрібен власний код, пакет генерує їх за допомогою random_int() і виключає неоднозначні символи на кшталт 0/O та 1/I, щоб ніхто не прочитав код неправильно з друкованої картки:
LaravelDiscount::generateCode(); // "8FJ2K9QW"
LaravelDiscount::generateCodes(100, 'VIP'); // Колекція зі 100 унікальних кодів
Довжина, алфавіт, префікс та роздільник налаштовуються під ключем codes у config/laravel-discount.php.
Обмежені за часом пропозиції використовують starts_at та expires_at. Застосування до початку вікна викидає DiscountNotStartedException, після закінчення - DiscountExpiredException та запускає подію DiscountExpired. Запит актуальних знижок реалізований через scope:
Discount::query()->valid()->get();
usage_limit обмежує загальну кількість використань, а usage_limit_per_user - на клієнта, але жоден з них не фіксується в момент застосування. Викликайте redeem(), коли замовлення фактично завершене:
LaravelDiscount::redeem($discount, $user, $result->discountAmount);
Це виконується в транзакції та інкрементує used_count з перевіркою ліміту в where, тому база даних вирішує, чи відбудеться інкремент взагалі. Якщо оновлено нуль рядків - ліміт вже досягнуто, що проявляється як DiscountUsageLimitReachedException. Два клієнти, які претендують на сотий слот одночасно, не можуть обидва його отримати.
Гості отримують такий самий підхід через ID сесії замість моделі користувача, що відстежується в колонці session_id таблиці discount_usages поряд з nullable user_id:
$result = LaravelDiscount::applyCode('GUEST10', $total, sessionId: session()->getId());
LaravelDiscount::redeem($discount, amount: $result->discountAmount, sessionId: session()->getId());
Умовні та стекувальні знижки
Колонка min_order_value блокує знижку за порогом витрат і викидає MinimumOrderValueException, коли сума недостатня. Також є JSON-колонка conditions для зберігання власних даних умов - туди можна покласти все, що пакет не моделює нативно. Якщо хочете виражати умови як композитні PHP-об'єкти, пакет Discountify використовує саме такий підхід.
Знижки можуть прикріплюватися до моделей. Додайте трейт HasDiscounts, і отримаєте поліморфне відношення, підкріплене таблицею discountables:
use Binafy\LaravelDiscount\Traits\HasDiscounts;
class Product extends Model
{
use HasDiscounts;
}
$product->discounts()->attach($discount);
$product->validDiscounts();
$product->hasDiscount('TECH10');
$result = $product->applyDiscounts($product->price);
Останній виклик маршрутизується через applyMany() - це резолвер стекування. Він відкидає знижки, що не пройшли валідацію, ділить решту на стекувальні та не-стекувальні, сумує стекувальні (з обмеженням загальної суми замовлення), знаходить одну найкращу не-стекувальну і повертає ту сторону, яка економить більше:
$result = LaravelDiscount::applyMany([$tenPercent, $tenFixed, $bigSolo], 100);
$result->discounts; // ті, що фактично застосувалися
$result->discountAmount; // переможна сума
Колекція discounts у результаті важлива саме тут. Після рішення про стекування часто потрібно показати клієнту, які коди пройшли, і ця колекція - відповідь.
Інтеграція з Laravel Cart
Встановіть binafy/laravel-cart - пакет кошика від того самого автора, який ми розглядали раніше, і сервіс CartDiscount стає доступним для знижок на рівні кошика та товару:
use Binafy\LaravelDiscount\Integrations\LaravelCart\CartDiscount;
$cartDiscount = app(CartDiscount::class);
$result = $cartDiscount->applyToCart($cart, 'SUMMER-8FJ2K9QW');
$result = $cartDiscount->applyToItem($cartItem, $discount);
$result = $cartDiscount->applyItemDiscounts($cart);
applyToCart() перевіряє загальну суму кошика щодо min_order_value і витягує користувача кошика для ліміту на користувача, тому вам не потрібно передавати жодне з них. applyToItem() працює з ціною, помноженою на кількість для одного рядка. applyItemDiscounts() проходить кошик і застосовує все, що базова модель кожного товару має прикріпленим через HasDiscounts - так ви запускаєте розпродаж на рівні продукту по всьому кошику без торкання загальної суми.
Валідація, виключення та події
Форми чекауту повинні відхиляти недійсний код до того, як щось інше станеться, і пакет включає правило ValidDiscountCode для цього. Його повідомлення називає фактичну причину замість загального провалу:
use Binafy\LaravelDiscount\Rules\ValidDiscountCode;
public function rules(): array
{
return [
'code' => ['required', new ValidDiscountCode(
orderAmount: $this->cartTotal(),
user: $this->user(),
)],
];
}
Це стандартний об'єкт правила, тому він компонується з рештою form request так само, як будь-яке custom validation rule.
Поза валідацією кожен випадок провалу має власне виключення, що розширює DiscountException: DiscountNotFoundException, DiscountNotActiveException, DiscountNotStartedException, DiscountExpiredException, DiscountUsageLimitReachedException та MinimumOrderValueException. Кожне відкриває знижку, що провалилася, через getDiscount(), тому ви можете спіймати конкретний випадок, який хочете повідомити інакше, і дозволити базовому класу обробити решту:
try {
$result = LaravelDiscount::applyCode($code, $total, $user);
} catch (DiscountExpiredException $e) {
return back()->withErrors("Code {$e->getDiscount()->code} has expired.");
} catch (DiscountException $e) {
return back()->withErrors($e->getMessage());
}
Три події охоплюють життєвий цикл: DiscountApplied, коли знижки застосовуються до суми, DiscountRedeemed після комміту транзакції погашення, та DiscountExpired, коли валідація натрапляє на прострочену знижку. Подія погашення несе і знижку, і рядок використання, чого достатньо для аналітики або сповіщення без повторного запиту.
Встановлення
Laravel Discount потребує PHP 8.1+ та Laravel 9-13. Встановіть пакет через Composer і запустіть міграції:
composer require binafy/laravel-discount
php artisan migrate
Сервіс-провайдер реєструється автоматично, а міграції створюють таблиці discounts, discount_usages та discountables. Публікація конфігурації опціональна і має сенс лише якщо потрібно змінити назви таблиць, вказати на іншу модель користувача або налаштувати defaults генерації кодів:
php artisan vendor:publish --tag="laravel-discount-config"
Дві Artisan-команди йдуть разом з пакетом. discount:generate генерує коди з терміналу, а discount:prune видаляє прострочені знижки разом з їхніми записами використання, що розумно покласти в scheduler:
Schedule::command('discount:prune --days=30')->daily();
Повна документація, включаючи довідку конфігурації, знаходиться в репозиторії Laravel Discount на GitHub.