Documentation Index
Fetch the complete documentation index at: https://docs.laravelshopper.dev/llms.txt
Use this file to discover all available pages before exploring further.
Discounts allow you to create promotional offers for your customers. Shopper supports percentage and fixed amount discounts with flexible application rules, eligibility conditions, and usage limits.
Models
Shopper uses two models to manage discounts:
| Model | Purpose |
|---|
Shopper\Core\Models\Discount | The discount definition (code, type, value, rules) |
Shopper\Core\Models\DiscountDetail | Polymorphic link connecting discounts to specific products or customers |
The Discount model implements Shopper\Core\Models\Contracts\Discount. Neither model is configurable via config/shopper/models.php.
Database Schema
Discount Table
| Column | Type | Nullable | Default | Description |
|---|
id | bigint | no | auto | Primary key |
code | string | no | - | Unique discount code |
type | string | no | - | Discount type enum |
value | integer | no | - | Discount value (percentage or cents) |
is_active | boolean | no | false | Discount visibility |
apply_to | string | no | - | Application scope enum |
min_required | string | no | - | Requirement type enum |
min_required_value | string | yes | null | Minimum value required |
eligibility | string | no | - | Eligibility type enum |
usage_limit | integer | yes | null | Maximum total uses |
usage_limit_per_user | boolean | no | false | Limit one use per customer |
total_use | integer | no | 0 | Current total usage count |
start_at | datetime | no | - | Discount start date |
end_at | datetime | yes | null | Discount end date (null = no expiry) |
zone_id | bigint | yes | null | FK to zone (null = all zones) |
metadata | json | yes | null | Additional custom data |
created_at | timestamp | yes | null | Creation timestamp |
updated_at | timestamp | yes | null | Last update timestamp |
Discountable Table (Pivot)
| Column | Type | Nullable | Description |
|---|
id | bigint | no | Primary key |
condition | string | yes | Condition type (apply_to, eligibility) |
total_use | integer | no | Usage count for this relation |
discountable_id | bigint | no | Polymorphic relation ID |
discountable_type | string | no | Polymorphic relation type |
discount_id | bigint | no | FK to discount |
Discount Type
The DiscountType enum defines value types:
use Shopper\Core\Enum\DiscountType;
DiscountType::Percentage // percentage - e.g., 10% off
DiscountType::FixedAmount // fixed_amount - e.g., $10 off
| Type | Database Value | Value Handling |
|---|
| Percentage | percentage | Stored as-is (10 = 10%) |
| Fixed Amount | fixed_amount | Stored in cents (1000 = $10.00) |
Discount Application
The DiscountApplyTo enum defines where the discount applies:
use Shopper\Core\Enum\DiscountApplyTo;
DiscountApplyTo::Order // order - Applies to entire order
DiscountApplyTo::Products // products - Applies to specific products
| Scope | Database Value | Description |
|---|
| Order | order | Discount applies to total order amount |
| Products | products | Discount applies only to linked products |
Discount Eligibility
The DiscountEligibility enum defines who can use the discount:
use Shopper\Core\Enum\DiscountEligibility;
DiscountEligibility::Everyone // everyone - All customers
DiscountEligibility::Customers // customers - Specific customers only
| Eligibility | Database Value | Description |
|---|
| Everyone | everyone | Any customer can use |
| Customers | customers | Only linked customers can use |
Discount Requirements
The DiscountRequirement enum defines minimum conditions:
use Shopper\Core\Enum\DiscountRequirement;
DiscountRequirement::None // none - No minimum
DiscountRequirement::Price // price - Minimum order amount
DiscountRequirement::Quantity // quantity - Minimum items count
| Requirement | Database Value | min_required_value |
|---|
| None | none | Not used |
| Price | price | Minimum amount in cents |
| Quantity | quantity | Minimum item count |
Discount Condition
The DiscountCondition enum is used on the DiscountDetail pivot model to distinguish whether a linked record represents a product (the discount applies to) or a customer (who is eligible):
use Shopper\Core\Enum\DiscountCondition;
DiscountCondition::ApplyTo // apply_to - Links a product to the discount
DiscountCondition::Eligibility // eligibility - Links a customer to the discount
Relationships
Items (DiscountDetail)
use Shopper\Core\Enum\DiscountCondition;
// Get all discount items (products or customers)
$discount->items; // Collection of DiscountDetail models
// Add a product to discount
$discount->items()->create([
'condition' => DiscountCondition::ApplyTo,
'discountable_type' => Product::class,
'discountable_id' => $productId,
]);
// Add eligible customer
$discount->items()->create([
'condition' => DiscountCondition::Eligibility,
'discountable_type' => User::class,
'discountable_id' => $userId,
]);
Zone
// Get discount zone (market)
$discount->zone; // Zone model or null
// Restrict discount to a zone
$discount->update(['zone_id' => $zoneId]);
Value Handling
Fixed amount values are stored in cents. Percentage values are stored as the percentage number.
For a $10 fixed discount, store 1000 (cents). For a 15% percentage discount, store 15:
Discount::query()->create([
'code' => 'SAVE10',
'type' => DiscountType::FixedAmount,
'value' => 1000, // 1000 cents = $10.00
]);
Discount::query()->create([
'code' => 'SAVE15',
'type' => DiscountType::Percentage,
'value' => 15, // 15%
]);
Usage Limit Enforcement
Discounts can cap the total number of redemptions with usage_limit and restrict each customer to one redemption with usage_limit_per_user. Both limits are enforced with strong consistency guarantees so that a coupon can never be silently over-redeemed under concurrent checkout.
Global Usage Limit
The usage_limit column caps the total number of redemptions across all customers. The total_use counter is incremented atomically inside the CreateOrderFromCartAction transaction:
- The discount row is locked with
lockForUpdate to serialize concurrent checkouts.
- A conditional
UPDATE increments total_use only if it is still below usage_limit.
- If the conditional update affects zero rows, the limit was exhausted between cart validation and commit.
DiscountLimitReachedException::global() is thrown and the order transaction rolls back, so no order is created.
This compare-and-swap pattern is what fixes the silent over-redemption issue that was patched in v2.8.
Per-User Limit
When usage_limit_per_user is true, each customer can redeem the discount at most once. The check counts prior orders on the orders.discount_id column (introduced in v2.8), not the legacy DiscountDetail.total_use counter:
use Shopper\Models\Order;
Order::query()
->where('discount_id', $discount->id)
->where('customer_id', $cart->customer_id)
->exists();
The same query runs in two places. DiscountValidator rejects the code at cart-apply time so the customer is not surprised at checkout. CreateOrderFromCartAction re-checks at commit and throws DiscountLimitReachedException::perUser() if the customer redeemed it between cart apply and commit.
Helpers
$discount->hasReachedLimit();
if ($discount->usage_limit !== null) {
$remaining = $discount->usage_limit - $discount->total_use;
}
Handling DiscountLimitReachedException
Wrap your checkout flow to surface a friendly message to the customer when either limit is hit at commit time:
use Shopper\Cart\Actions\CreateOrderFromCartAction;
use Shopper\Cart\Exceptions\DiscountLimitReachedException;
try {
$order = resolve(CreateOrderFromCartAction::class)->execute($cart);
} catch (DiscountLimitReachedException $e) {
return back()->with('error', 'This discount can no longer be used.');
}
The exception has two static constructors that match the two limit types:
| Constructor | Thrown When |
|---|
DiscountLimitReachedException::global($code) | Global usage_limit was reached between cart validation and order commit |
DiscountLimitReachedException::perUser($code) | Customer already redeemed a discount with usage_limit_per_user set |
Creating Discounts
Percentage Discount for All Orders
use Shopper\Core\Models\Discount;
use Shopper\Core\Enum\DiscountType;
use Shopper\Core\Enum\DiscountApplyTo;
use Shopper\Core\Enum\DiscountEligibility;
use Shopper\Core\Enum\DiscountRequirement;
$discount = Discount::query()->create([
'code' => 'SUMMER20',
'type' => DiscountType::Percentage,
'value' => 20, // 20% off
'is_active' => true,
'apply_to' => DiscountApplyTo::Order,
'eligibility' => DiscountEligibility::Everyone,
'min_required' => DiscountRequirement::None,
'start_at' => now(),
'end_at' => now()->addMonth(),
]);
Fixed Amount Discount with Minimum Order
$discount = Discount::query()->create([
'code' => 'SAVE10',
'type' => DiscountType::FixedAmount,
'value' => 1000, // 1000 cents = $10 off
'is_active' => true,
'apply_to' => DiscountApplyTo::Order,
'eligibility' => DiscountEligibility::Everyone,
'min_required' => DiscountRequirement::Price,
'min_required_value' => '5000', // Minimum 5000 cents = $50 order
'start_at' => now(),
'usage_limit' => 100, // Max 100 uses
]);
Product-Specific Discount
$discount = Discount::query()->create([
'code' => 'PRODUCT15',
'type' => DiscountType::Percentage,
'value' => 15,
'is_active' => true,
'apply_to' => DiscountApplyTo::Products,
'eligibility' => DiscountEligibility::Everyone,
'min_required' => DiscountRequirement::None,
'start_at' => now(),
]);
// Link specific products
$discount->items()->createMany([
[
'condition' => 'apply_to',
'discountable_type' => Product::class,
'discountable_id' => $product1->id,
],
[
'condition' => 'apply_to',
'discountable_type' => Product::class,
'discountable_id' => $product2->id,
],
]);
VIP Customer Discount
$discount = Discount::query()->create([
'code' => 'VIP30',
'type' => DiscountType::Percentage,
'value' => 30,
'is_active' => true,
'apply_to' => DiscountApplyTo::Order,
'eligibility' => DiscountEligibility::Customers,
'min_required' => DiscountRequirement::None,
'start_at' => now(),
'usage_limit_per_user' => true, // One use per customer
]);
// Link eligible customers
foreach ($vipCustomers as $customer) {
$discount->items()->create([
'condition' => 'eligibility',
'discountable_type' => User::class,
'discountable_id' => $customer->id,
]);
}
Retrieving Discounts
use Shopper\Core\Models\Discount;
// Get all active discounts
$discounts = Discount::query()
->where('is_active', true)
->where('start_at', '<=', now())
->where(function ($q) {
$q->whereNull('end_at')
->orWhere('end_at', '>', now());
})
->get();
// Get discount by code
$discount = Discount::query()
->where('code', 'SUMMER20')
->where('is_active', true)
->first();
// Get discounts for a zone
$discounts = Discount::query()
->where('is_active', true)
->where(function ($q) use ($zoneId) {
$q->whereNull('zone_id')
->orWhere('zone_id', $zoneId);
})
->get();
// Get discounts with available uses
$discounts = Discount::query()
->where('is_active', true)
->where(function ($q) {
$q->whereNull('usage_limit')
->orWhereColumn('total_use', '<', 'usage_limit');
})
->get();
Discount Validation
When a coupon is applied to a cart via CartManager::applyCoupon(), the Cart package validates the discount automatically during the calculation pipeline. You do not need to build manual validation logic. The built-in DiscountValidator checks all rules (active status, date range, usage limits, eligibility, zone, minimum amounts) and produces clear error messages.
For cases outside the cart pipeline where you need to check a discount programmatically:
use Shopper\Core\Models\Discount;
$discount = Discount::query()
->where('code', $code)
->where('is_active', true)
->first();
if ($discount && ! $discount->hasReachedLimit()) {
// Discount is valid and has remaining uses
}
Configuration
Disabling Discounts
If your store doesn’t use discount codes, disable the feature in config/shopper/features.php:
use Shopper\Enum\FeatureState;
return [
'discount' => FeatureState::Disabled,
];
Permissions
The admin panel generates five permissions for discount management:
| Permission | Description |
|---|
browse_discounts | View the discounts list |
read_discounts | View a single discount |
add_discounts | Create new discounts |
edit_discounts | Edit existing discounts |
delete_discounts | Delete discounts |
Components
To customize the admin UI for discount management:
php artisan shopper:component:publish discount
Creates config/shopper/components/discount.php:
use Shopper\Livewire;
return [
'pages' => [
'discount-index' => Livewire\Pages\Discount\Index::class,
],
'components' => [
'slide-overs.discount-form' => Livewire\SlideOvers\DiscountForm::class,
],
];
Storefront Example
Applying a discount code on the storefront uses the Cart package API. The CartManager::applyCoupon() method validates the code exists, and the calculation pipeline handles the rest (validation rules, discount calculation, and adjustment creation):
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Shopper\Cart\CartManager;
use Shopper\Cart\Exceptions\InvalidDiscountException;
use Shopper\Cart\Facades\Cart;
class CouponController extends Controller
{
public function apply(Request $request)
{
$request->validate(['code' => 'required|string']);
$manager = resolve(CartManager::class);
try {
$manager->applyCoupon(Cart::current(), $request->code);
} catch (InvalidDiscountException $e) {
return back()->with('error', 'Invalid discount code.');
}
return back()->with('success', 'Discount applied.');
}
public function remove()
{
resolve(CartManager::class)->removeCoupon(Cart::current());
return back()->with('success', 'Discount removed.');
}
}
Use Cases
| Scenario | Type | Apply To | Eligibility | Requirement |
|---|
| Site-wide sale | Percentage | Order | Everyone | None |
| Free shipping over $50 | Fixed | Order | Everyone | Price |
| Buy 3+ get 10% off | Percentage | Order | Everyone | Quantity |
| VIP member exclusive | Percentage | Order | Customers | None |
| Product clearance | Percentage | Products | Everyone | None |
| First order discount | Fixed | Order | Customers | None |