---
title: "Laravel Discount: знижки, промокоди, ліміти використання та стекування"
url: https://laravelukraine.com/blog/laravel-discount-znizki-promokodi-limiti-vikoristannia-ta-stekuvannia
date: 2026-08-12
source: https://laravel-news.com/laravel-discount?utm_medium=feed&utm_source=feedpress.me&utm_campaign=Feed%3A+laravelnews
---

# Laravel Discount: знижки, промокоди, ліміти використання та стекування

E-commerce магазини часто потребують більше, ніж просту знижку у відсотках. Інтернет-магазин може запускати сезонний розпродаж, SaaS-продукт - роздавати промокоди на запуск, а знижка в 10% може мати максимальний ліміт, прив'язуватися до мінімальної суми замовлення, обмежуватися кількістю використань на клієнта та мати термін дії. Пакет Laravel Discount від [Milwad Khosravi](https://github.com/milwad-dev) зберігає всю цю інформацію в 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-модель, тому створюється як звичайний запис:

```php
use Binafy\LaravelDiscount\Enums\DiscountType;
use Binafy\LaravelDiscount\Models\Discount;

$discount = Discount::query()->create([
    'name' => 'Summer Sale',
    'type' => DiscountType::Percentage,
    'value' => 20,
]);
```

Застосування відбувається через фасад `LaravelDiscount`, який валідує знижку перед розрахунками і повертає об'єкт `DiscountResult`:

```php
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", який інакше доводиться жорстко кодувати в контролері:

```php
$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`, якщо відповідності не знайдено:

```php
$result = LaravelDiscount::applyCode('WELCOME10', 200, $user);
```

Для кампаній, де кожному клієнту потрібен власний код, пакет генерує їх за допомогою `random_int()` і виключає неоднозначні символи на кшталт `0`/`O` та `1`/`I`, щоб ніхто не прочитав код неправильно з друкованої картки:

```php
LaravelDiscount::generateCode();            // "8FJ2K9QW"
LaravelDiscount::generateCodes(100, 'VIP'); // Колекція зі 100 унікальних кодів
```

Довжина, алфавіт, префікс та роздільник налаштовуються під ключем `codes` у `config/laravel-discount.php`.

Обмежені за часом пропозиції використовують `starts_at` та `expires_at`. Застосування до початку вікна викидає `DiscountNotStartedException`, після закінчення - `DiscountExpiredException` та запускає подію `DiscountExpired`. Запит актуальних знижок реалізований через scope:

```php
Discount::query()->valid()->get();
```

`usage_limit` обмежує загальну кількість використань, а `usage_limit_per_user` - на клієнта, але жоден з них не фіксується в момент застосування. Викликайте `redeem()`, коли замовлення фактично завершене:

```php
LaravelDiscount::redeem($discount, $user, $result->discountAmount);
```

Це виконується в транзакції та інкрементує `used_count` з перевіркою ліміту в `where`, тому база даних вирішує, чи відбудеться інкремент взагалі. Якщо оновлено нуль рядків - ліміт вже досягнуто, що проявляється як `DiscountUsageLimitReachedException`. Два клієнти, які претендують на сотий слот одночасно, не можуть обидва його отримати.

Гості отримують такий самий підхід через ID сесії замість моделі користувача, що відстежується в колонці `session_id` таблиці `discount_usages` поряд з nullable `user_id`:

```php
$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](https://laravel-news.com/discountify) використовує саме такий підхід.

Знижки можуть прикріплюватися до моделей. Додайте трейт `HasDiscounts`, і отримаєте поліморфне відношення, підкріплене таблицею `discountables`:

```php
use Binafy\LaravelDiscount\Traits\HasDiscounts;

class Product extends Model
{
    use HasDiscounts;
}
```

```php
$product->discounts()->attach($discount);
$product->validDiscounts();
$product->hasDiscount('TECH10');
$result = $product->applyDiscounts($product->price);
```

Останній виклик маршрутизується через `applyMany()` - це резолвер стекування. Він відкидає знижки, що не пройшли валідацію, ділить решту на стекувальні та не-стекувальні, сумує стекувальні (з обмеженням загальної суми замовлення), знаходить одну найкращу не-стекувальну і повертає ту сторону, яка економить більше:

```php
$result = LaravelDiscount::applyMany([$tenPercent, $tenFixed, $bigSolo], 100);
$result->discounts;      // ті, що фактично застосувалися
$result->discountAmount; // переможна сума
```

Колекція `discounts` у результаті важлива саме тут. Після рішення про стекування часто потрібно показати клієнту, які коди пройшли, і ця колекція - відповідь.

## Інтеграція з Laravel Cart

Встановіть `binafy/laravel-cart` - пакет кошика від того самого автора, який ми [розглядали раніше](https://laravel-news.com/laravel-cart-package), і сервіс `CartDiscount` стає доступним для знижок на рівні кошика та товару:

```php
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` для цього. Його повідомлення називає фактичну причину замість загального провалу:

```php
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](https://laravel-news.com/custom-validation-rules).

Поза валідацією кожен випадок провалу має власне виключення, що розширює `DiscountException`: `DiscountNotFoundException`, `DiscountNotActiveException`, `DiscountNotStartedException`, `DiscountExpiredException`, `DiscountUsageLimitReachedException` та `MinimumOrderValueException`. Кожне відкриває знижку, що провалилася, через `getDiscount()`, тому ви можете спіймати конкретний випадок, який хочете повідомити інакше, і дозволити базовому класу обробити решту:

```php
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 і запустіть міграції:

```bash
composer require binafy/laravel-discount
php artisan migrate
```

Сервіс-провайдер реєструється автоматично, а міграції створюють таблиці `discounts`, `discount_usages` та `discountables`. Публікація конфігурації опціональна і має сенс лише якщо потрібно змінити назви таблиць, вказати на іншу модель користувача або налаштувати defaults генерації кодів:

```bash
php artisan vendor:publish --tag="laravel-discount-config"
```

Дві Artisan-команди йдуть разом з пакетом. `discount:generate` генерує коди з терміналу, а `discount:prune` видаляє прострочені знижки разом з їхніми записами використання, що розумно покласти в scheduler:

```php
Schedule::command('discount:prune --days=30')->daily();
```

Повна документація, включаючи довідку конфігурації, знаходиться в [репозиторії Laravel Discount на GitHub](https://github.com/binafy/laravel-discount).
