Skip to main content
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:
Shopper\Core\Models\Discount       // The discount definition
Shopper\Core\Models\DiscountDetail // Links discounts to products/customers
use Shopper\Core\Models\Discount;

// The model implements
- Shopper\Core\Models\Contracts\Discount

Database Schema

Discount Table

ColumnTypeNullableDefaultDescription
idbigintnoautoPrimary key
codestringno-Unique discount code
typestringno-Discount type enum
valueintegerno-Discount value (percentage or cents)
is_activebooleannofalseDiscount visibility
apply_tostringno-Application scope enum
min_requiredstringno-Requirement type enum
min_required_valuestringyesnullMinimum value required
eligibilitystringno-Eligibility type enum
usage_limitintegeryesnullMaximum total uses
usage_limit_per_userbooleannofalseLimit one use per customer
total_useintegerno0Current total usage count
start_atdatetimeno-Discount start date
end_atdatetimeyesnullDiscount end date (null = no expiry)
zone_idbigintyesnullFK to zone (null = all zones)
metadatajsonyesnullAdditional custom data
created_attimestampyesnullCreation timestamp
updated_attimestampyesnullLast update timestamp

Discountable Table (Pivot)

ColumnTypeNullableDescription
idbigintnoPrimary key
conditionstringyesCondition type (apply_to, eligibility)
total_useintegernoUsage count for this relation
discountable_idbigintnoPolymorphic relation ID
discountable_typestringnoPolymorphic relation type
discount_idbigintnoFK 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
TypeDatabase ValueValue Handling
PercentagepercentageStored as-is (10 = 10%)
Fixed Amountfixed_amountStored 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
ScopeDatabase ValueDescription
OrderorderDiscount applies to total order amount
ProductsproductsDiscount 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
EligibilityDatabase ValueDescription
EveryoneeveryoneAny customer can use
CustomerscustomersOnly 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
RequirementDatabase Valuemin_required_value
NonenoneNot used
PricepriceMinimum amount in cents
QuantityquantityMinimum item count

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]);

HasDiscounts Trait

Models can use the HasDiscounts trait to access their discounts:
use Shopper\Core\Models\Traits\HasDiscounts;

class User extends Authenticatable
{
    use HasDiscounts;
}

// Get user's available discounts
$user->discounts; // Collection of Discount models

Value Handling

Fixed amount values are automatically converted:
// Create a $10 off discount
$discount = Discount::query()->create([
    'code' => 'SAVE10',
    'type' => DiscountType::FixedAmount,
    'value' => 10, // Stored as 1000 (cents)
    // ...
]);

// Reading the value
$discount->value; // Returns 10 (dollars)

// Create a 15% discount
$discount = Discount::query()->create([
    'code' => 'SAVE15',
    'type' => DiscountType::Percentage,
    'value' => 15, // Stored as 15
    // ...
]);

Usage Limit Checking

// Check if discount has reached its usage limit
$discount->hasReachedLimit(); // true if total_use === usage_limit

// Check remaining uses
if ($discount->usage_limit !== null) {
    $remaining = $discount->usage_limit - $discount->total_use;
}

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' => 10, // $10 off
    'is_active' => true,
    'apply_to' => DiscountApplyTo::Order,
    'eligibility' => DiscountEligibility::Everyone,
    'min_required' => DiscountRequirement::Price,
    'min_required_value' => '5000', // Minimum $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();

Validating Discounts

class DiscountService
{
    public function validate(string $code, $user, $cart): ?Discount
    {
        $discount = Discount::query()
            ->where('code', $code)
            ->where('is_active', true)
            ->first();

        if (! $discount) {
            return null;
        }

        // Check dates
        if ($discount->start_at > now()) {
            return null; // Not started yet
        }

        if ($discount->end_at && $discount->end_at < now()) {
            return null; // Expired
        }

        // Check usage limit
        if ($discount->hasReachedLimit()) {
            return null;
        }

        // Check minimum requirements
        if ($discount->min_required === DiscountRequirement::Price) {
            if ($cart->total() < (int) $discount->min_required_value) {
                return null;
            }
        }

        if ($discount->min_required === DiscountRequirement::Quantity) {
            if ($cart->itemCount() < (int) $discount->min_required_value) {
                return null;
            }
        }

        // Check eligibility
        if ($discount->eligibility === DiscountEligibility::Customers) {
            $isEligible = $discount->items()
                ->where('condition', 'eligibility')
                ->where('discountable_type', get_class($user))
                ->where('discountable_id', $user->id)
                ->exists();

            if (! $isEligible) {
                return null;
            }
        }

        return $discount;
    }

    public function calculate(Discount $discount, int $amount): int
    {
        if ($discount->type === DiscountType::Percentage) {
            return (int) ($amount * ($discount->value / 100));
        }

        // Fixed amount (already in cents in DB, accessor returns dollars)
        return $discount->value * 100;
    }
}

Components

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

namespace App\Http\Controllers;

use App\Services\DiscountService;
use Shopper\Core\Models\Discount;

class CartController extends Controller
{
    public function __construct(
        private DiscountService $discountService
    ) {}

    public function applyDiscount(Request $request)
    {
        $validated = $request->validate([
            'code' => 'required|string',
        ]);

        $cart = Cart::current();
        $user = auth()->user();

        $discount = $this->discountService->validate(
            $validated['code'],
            $user,
            $cart
        );

        if (! $discount) {
            return back()->with('error', 'Invalid or expired discount code');
        }

        // Apply discount to cart
        $discountAmount = $this->discountService->calculate(
            $discount,
            $cart->subtotal()
        );

        $cart->applyDiscount($discount->id, $discountAmount);

        // Increment usage counter
        $discount->increment('total_use');

        return back()->with('success', 'Discount applied!');
    }
}

Use Cases

ScenarioTypeApply ToEligibilityRequirement
Site-wide salePercentageOrderEveryoneNone
Free shipping over $50FixedOrderEveryonePrice
Buy 3+ get 10% offPercentageOrderEveryoneQuantity
VIP member exclusivePercentageOrderCustomersNone
Product clearancePercentageProductsEveryoneNone
First order discountFixedOrderCustomersNone