Developing Promo Code and Coupon System for E-commerce
Promo codes — conversion and loyalty tool. Behind simple input field hides non-trivial logic: category restrictions, min spend, usage limits, compatibility with other discounts. Poor implementation leads to rule conflicts, incorrect totals, margin leaks. Building flexible system takes 4–7 business days.
Data Model
CREATE TABLE coupons (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
type VARCHAR(20) NOT NULL, -- 'percent', 'fixed', 'free_shipping', 'buy_x_get_y'
value NUMERIC(10,2), -- percentage or discount amount
min_order_amount NUMERIC(12,2) DEFAULT 0,
max_discount_amount NUMERIC(12,2),
usage_limit INT, -- NULL = unlimited
usage_per_user INT DEFAULT 1,
used_count INT DEFAULT 0,
starts_at TIMESTAMP,
expires_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
applies_to VARCHAR(20) DEFAULT 'all', -- 'all', 'categories', 'products', 'users'
metadata JSONB DEFAULT '{}'
);
CREATE TABLE coupon_usages (
id BIGSERIAL PRIMARY KEY,
coupon_id BIGINT REFERENCES coupons(id),
user_id BIGINT REFERENCES users(id),
guest_email VARCHAR(255),
order_id BIGINT REFERENCES orders(id),
discount_amount NUMERIC(12,2),
used_at TIMESTAMP DEFAULT NOW()
);
metadata in JSONB stores restrictions: applicable categories, specific SKUs, user segments.
Coupon Types
| Type | Example | Logic |
|---|---|---|
percent |
SAVE20 → −20% | total * (value / 100), capped by max_discount_amount |
fixed |
MINUS500 → −500 ₽ | Fixed sum, not exceeding total |
free_shipping |
FREESHIP | Zeroes shipping cost |
buy_x_get_y |
BUY3GET1 | Free product or discount on Nth item |
first_order |
FIRST10 | 10% for first account/email order |
Coupon Validation
Multi-level check before applying:
class CouponValidator {
public function validate(string $code, Cart $cart, ?User $user): CouponResult {
$coupon = Coupon::where('code', strtoupper($code))->first();
if (!$coupon || !$coupon->is_active) {
return CouponResult::invalid('Promo code not found');
}
if ($coupon->expires_at && $coupon->expires_at->isPast()) {
return CouponResult::invalid('Promo code expired');
}
if ($coupon->starts_at && $coupon->starts_at->isFuture()) {
return CouponResult::invalid('Promo code not active yet');
}
if ($coupon->usage_limit && $coupon->used_count >= $coupon->usage_limit) {
return CouponResult::invalid('Promo code exhausted');
}
if ($cart->subtotal < $coupon->min_order_amount) {
return CouponResult::invalid("Min order: {$coupon->min_order_amount} ₽");
}
if ($user && $coupon->usage_per_user) {
$userUsages = CouponUsage::where('coupon_id', $coupon->id)
->where('user_id', $user->id)
->count();
if ($userUsages >= $coupon->usage_per_user) {
return CouponResult::invalid('You already used this code');
}
}
return CouponResult::valid($coupon, $this->calculateDiscount($coupon, $cart));
}
}
Category-Specific Discounts
If coupon applies only to specific categories:
private function calculateDiscount(Coupon $coupon, Cart $cart): float {
$applicableItems = $cart->items;
if ($coupon->applies_to === 'categories') {
$categoryIds = $coupon->metadata['category_ids'] ?? [];
$applicableItems = $cart->items->filter(
fn($item) => in_array($item->product->category_id, $categoryIds)
);
}
$applicableTotal = $applicableItems->sum(fn($i) => $i->price * $i->quantity);
$discount = match($coupon->type) {
'percent' => $applicableTotal * ($coupon->value / 100),
'fixed' => min($coupon->value, $applicableTotal),
default => 0,
};
if ($coupon->max_discount_amount) {
$discount = min($discount, $coupon->max_discount_amount);
}
return round($discount, 2);
}
Atomic Application and Usage Counter
On order creation coupon application atomic with lockForUpdate:
DB::transaction(function () use ($coupon, $order, $user) {
$locked = Coupon::lockForUpdate()->find($coupon->id);
if ($locked->usage_limit && $locked->used_count >= $locked->usage_limit) {
throw new CouponExhaustedException();
}
$locked->increment('used_count');
CouponUsage::create([
'coupon_id' => $locked->id,
'user_id' => $user?->id,
'order_id' => $order->id,
'discount_amount' => $order->discount_amount,
]);
});
Bulk Code Generation
For marketing campaigns generate thousands of unique codes:
Artisan::call('coupons:generate', [
'--count' => 1000,
'--prefix' => 'PROMO24',
'--type' => 'percent',
'--value' => 15,
'--expires' => '2024-12-31',
'--limit' => 1,
]);
Codes formatted as PROMO24-{XXXXXXXX} — 8 random chars from [A-Z0-9] without ambiguous (O, 0, I, 1).
UX in Cart
Coupon input — secondary element, no competition with "Checkout". Recommended:
- Field collapsed by default, expands on "Have code?"
- After input — instant check (debounce 500ms)
- Success: green check, total recalc, remove button
- Error: red text with reason
- One code at a time (unless business logic says otherwise)
Effectiveness Analytics
Track: applications per day, total discount amount, converter with/without coupon, average check with coupon. Evaluate specific campaign ROI.







