E-commerce Discount Engine
Difficulty: Intermediate | Time: 90 minutes | Integration: Collections, Caching, Middleware, Policies
Transform a complex e-commerce discount system with multiple business rules, seasonal promotions, user eligibility requirements, and performance optimizations.
The Problem: Complex Discount Logic
Your e-commerce platform has grown organically and now has a nightmarish discount calculation system spread across multiple services, with no clear way to understand or modify the business rules.
🔍 Examining the Existing Code
The current DiscountService
is a maintenance nightmare:
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\User;
use App\Models\Product;
class DiscountService
{
public function calculateDiscount(Order $order, User $user): float
{
$totalDiscount = 0;
$orderTotal = $order->subtotal;
// Volume discount logic
if ($orderTotal >= 500) {
if ($user->created_at <= now()->subYear() && $user->orders()->count() >= 10) {
$totalDiscount += $orderTotal * 0.15; // 15% for loyal high-volume customers
} else {
$totalDiscount += $orderTotal * 0.10; // 10% for new high-volume customers
}
} elseif ($orderTotal >= 200) {
$totalDiscount += $orderTotal * 0.05; // 5% for medium volume
}
// Membership discounts
if ($user->membership_tier === 'gold' && $user->membership_expires_at > now()) {
$totalDiscount += $orderTotal * 0.08; // 8% gold member discount
// Gold member bonus for electronics
$electronicsTotal = $order->items()->whereHas('product', function($q) {
$q->where('category', 'electronics');
})->sum('subtotal');
if ($electronicsTotal > 100) {
$totalDiscount += $electronicsTotal * 0.05; // Additional 5% on electronics
}
} elseif ($user->membership_tier === 'silver' && $user->membership_expires_at > now()) {
$totalDiscount += $orderTotal * 0.05; // 5% silver member discount
}
// Seasonal promotions (hardcoded - nightmare to maintain!)
$now = now();
if (($now->month === 11 && $now->day >= 20) || ($now->month === 12 && $now->day <= 5)) {
// Black Friday / Cyber Monday
$totalDiscount += $orderTotal * 0.20;
} elseif ($now->month === 7 && $now->day >= 1 && $now->day <= 7) {
// Independence Day sale
$totalDiscount += $orderTotal * 0.12;
}
// First-time buyer discount
if ($user->orders()->count() === 0) {
$totalDiscount += min($orderTotal * 0.10, 50); // 10% up to $50
}
// Category-specific discounts (more hardcoded rules!)
$booksTotal = $order->items()->whereHas('product', function($q) {
$q->where('category', 'books');
})->sum('subtotal');
if ($booksTotal >= 50 && $now->dayOfWeek === 0) { // Sunday
$totalDiscount += $booksTotal * 0.15; // Sunday book discount
}
// Referral bonuses
if ($user->referred_by && $user->created_at >= now()->subMonth()) {
$totalDiscount += min($orderTotal * 0.05, 25); // 5% up to $25 for recent referrals
}
// Make sure we don't exceed order total
return min($totalDiscount, $orderTotal * 0.50); // Max 50% discount
}
public function getAvailableDiscounts(User $user): array
{
// Duplicate logic again! 😱
$discounts = [];
if ($user->membership_tier === 'gold' && $user->membership_expires_at > now()) {
$discounts[] = 'Gold Member: 8% off + 5% electronics bonus';
}
// ... more duplicate logic
return $discounts;
}
}
🎯 Business Logic Analysis
Let's identify the distinct business rules:
- Volume Discounts: Based on order total and customer loyalty
- Membership Tiers: Gold (8%) and Silver (5%) with special rules
- Category Bonuses: Electronics bonus for gold members, Sunday book discounts
- Seasonal Promotions: Black Friday, Independence Day, etc.
- First-Time Buyer: 10% up to $50 for new customers
- Referral Program: 5% up to $25 for recent referrals
- Maximum Discount: Cap at 50% of order total
📋 Specification Strategy
We'll create a sophisticated system with:
- Individual discount specifications
- Composable discount calculators
- Caching for performance
- Middleware for access control
- Policy integration for admin features
Step 1: Generate Base Specifications
# Core discount specifications
php artisan make:specification Discount/VolumeDiscountSpecification --model=Order
php artisan make:specification Discount/MembershipDiscountSpecification --model=User
php artisan make:specification Discount/CategoryBonusSpecification --model=Order
php artisan make:specification Discount/SeasonalPromotionSpecification --model=Order
php artisan make:specification Discount/FirstTimeBuyerSpecification --model=User
php artisan make:specification Discount/ReferralBonusSpecification --model=User
# Composite specifications
php artisan make:specification Discount/EligibleForDiscountSpecification --composite
php artisan make:specification Discount/MaximumDiscountSpecification --composite
Step 2: Implement Core Specifications
VolumeDiscountSpecification
<?php
namespace App\Specifications\Discount;
use App\Models\Order;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
class VolumeDiscountSpecification extends AbstractSpecification
{
public function __construct(
private readonly float $minimumAmount = 200.00,
private readonly bool $enableCaching = true
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof Order) {
return false;
}
return $candidate->subtotal >= $this->minimumAmount;
}
public function toQuery(Builder $query): Builder
{
return $query->where('subtotal', '>=', $this->minimumAmount);
}
public function calculateDiscountAmount(Order $order): float
{
if (!$this->isSatisfiedBy($order)) {
return 0;
}
$cacheKey = "volume_discount_{$order->id}_{$order->updated_at->timestamp}";
if ($this->enableCaching) {
return Cache::remember($cacheKey, 300, function () use ($order) {
return $this->performCalculation($order);
});
}
return $this->performCalculation($order);
}
private function performCalculation(Order $order): float
{
$customer = $order->customer;
$subtotal = $order->subtotal;
if ($subtotal >= 500) {
// High volume discount
$isLoyal = $customer->created_at <= now()->subYear()
&& $customer->orders()->count() >= 10;
return $subtotal * ($isLoyal ? 0.15 : 0.10);
}
if ($subtotal >= 200) {
return $subtotal * 0.05;
}
return 0;
}
}
MembershipDiscountSpecification
<?php
namespace App\Specifications\Discount;
use App\Models\User;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class MembershipDiscountSpecification extends AbstractSpecification
{
public function __construct(
private readonly ?string $requiredTier = null
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
$hasValidMembership = !is_null($candidate->membership_tier)
&& $candidate->membership_expires_at > now();
if (!$hasValidMembership) {
return false;
}
if ($this->requiredTier) {
return $candidate->membership_tier === $this->requiredTier;
}
return true;
}
public function toQuery(Builder $query): Builder
{
$query = $query->whereNotNull('membership_tier')
->where('membership_expires_at', '>', now());
if ($this->requiredTier) {
$query->where('membership_tier', $this->requiredTier);
}
return $query;
}
public function getDiscountRate(User $user): float
{
if (!$this->isSatisfiedBy($user)) {
return 0;
}
return match ($user->membership_tier) {
'gold' => 0.08,
'silver' => 0.05,
'bronze' => 0.03,
default => 0
};
}
}
SeasonalPromotionSpecification
<?php
namespace App\Specifications\Discount;
use App\Models\Order;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class SeasonalPromotionSpecification extends AbstractSpecification
{
private array $promotions;
public function __construct(?Carbon $date = null)
{
$this->date = $date ?? now();
$this->promotions = $this->loadPromotions();
}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof Order) {
return false;
}
return $this->getActivePromotion() !== null;
}
public function toQuery(Builder $query): Builder
{
$activePromo = $this->getActivePromotion();
if (!$activePromo) {
return $query->whereRaw('1 = 0'); // No active promotion
}
// In a real implementation, you might filter by order date
return $query->where('created_at', '>=', $activePromo['start'])
->where('created_at', '<=', $activePromo['end']);
}
public function getDiscountRate(): float
{
$promotion = $this->getActivePromotion();
return $promotion ? $promotion['rate'] : 0;
}
public function getPromotionName(): ?string
{
$promotion = $this->getActivePromotion();
return $promotion ? $promotion['name'] : null;
}
private function loadPromotions(): array
{
// In production, load from database or config
return [
'black_friday' => [
'name' => 'Black Friday Sale',
'start' => $this->date->copy()->month(11)->day(20),
'end' => $this->date->copy()->month(12)->day(5),
'rate' => 0.20,
],
'independence_day' => [
'name' => 'Independence Day Sale',
'start' => $this->date->copy()->month(7)->day(1),
'end' => $this->date->copy()->month(7)->day(7),
'rate' => 0.12,
],
'summer_clearance' => [
'name' => 'Summer Clearance',
'start' => $this->date->copy()->month(8)->day(15),
'end' => $this->date->copy()->month(8)->day(31),
'rate' => 0.15,
],
];
}
private function getActivePromotion(): ?array
{
foreach ($this->promotions as $promotion) {
if ($this->date->between($promotion['start'], $promotion['end'])) {
return $promotion;
}
}
return null;
}
}
Step 3: Create Discount Calculator Service
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\User;
use App\Specifications\Discount\VolumeDiscountSpecification;
use App\Specifications\Discount\MembershipDiscountSpecification;
use App\Specifications\Discount\SeasonalPromotionSpecification;
use App\Specifications\Discount\FirstTimeBuyerSpecification;
use App\Specifications\Discount\ReferralBonusSpecification;
use Illuminate\Support\Facades\Cache;
class DiscountCalculatorService
{
private array $discountSpecs;
public function __construct(
VolumeDiscountSpecification $volumeSpec,
MembershipDiscountSpecification $membershipSpec,
SeasonalPromotionSpecification $seasonalSpec,
FirstTimeBuyerSpecification $firstTimerSpec,
ReferralBonusSpecification $referralSpec
) {
$this->discountSpecs = [
'volume' => $volumeSpec,
'membership' => $membershipSpec,
'seasonal' => $seasonalSpec,
'first_time' => $firstTimerSpec,
'referral' => $referralSpec,
];
}
public function calculateTotalDiscount(Order $order): array
{
$cacheKey = "total_discount_{$order->id}_{$order->updated_at->timestamp}";
return Cache::remember($cacheKey, 600, function () use ($order) {
return $this->performDiscountCalculation($order);
});
}
private function performDiscountCalculation(Order $order): array
{
$customer = $order->customer;
$discounts = [];
$totalDiscount = 0;
// Volume discount
if ($this->discountSpecs['volume']->isSatisfiedBy($order)) {
$amount = $this->discountSpecs['volume']->calculateDiscountAmount($order);
$discounts['volume'] = ['amount' => $amount, 'description' => 'Volume discount'];
$totalDiscount += $amount;
}
// Membership discount
if ($this->discountSpecs['membership']->isSatisfiedBy($customer)) {
$rate = $this->discountSpecs['membership']->getDiscountRate($customer);
$amount = $order->subtotal * $rate;
$discounts['membership'] = [
'amount' => $amount,
'description' => ucfirst($customer->membership_tier) . ' member discount'
];
$totalDiscount += $amount;
}
// Seasonal promotion
if ($this->discountSpecs['seasonal']->isSatisfiedBy($order)) {
$rate = $this->discountSpecs['seasonal']->getDiscountRate();
$amount = $order->subtotal * $rate;
$discounts['seasonal'] = [
'amount' => $amount,
'description' => $this->discountSpecs['seasonal']->getPromotionName()
];
$totalDiscount += $amount;
}
// First-time buyer discount
if ($this->discountSpecs['first_time']->isSatisfiedBy($customer)) {
$amount = min($order->subtotal * 0.10, 50);
$discounts['first_time'] = ['amount' => $amount, 'description' => 'First-time buyer discount'];
$totalDiscount += $amount;
}
// Referral bonus
if ($this->discountSpecs['referral']->isSatisfiedBy($customer)) {
$amount = min($order->subtotal * 0.05, 25);
$discounts['referral'] = ['amount' => $amount, 'description' => 'Referral bonus'];
$totalDiscount += $amount;
}
// Apply maximum discount cap (50%)
$maxDiscount = $order->subtotal * 0.50;
if ($totalDiscount > $maxDiscount) {
$totalDiscount = $maxDiscount;
$discounts['capped'] = true;
}
return [
'total_discount' => $totalDiscount,
'discounts' => $discounts,
'original_total' => $order->subtotal,
'final_total' => $order->subtotal - $totalDiscount,
];
}
public function getAvailableDiscounts(User $user): array
{
$available = [];
if ($this->discountSpecs['membership']->isSatisfiedBy($user)) {
$rate = $this->discountSpecs['membership']->getDiscountRate($user);
$available[] = ucfirst($user->membership_tier) . " member: " . ($rate * 100) . "% off";
}
if ($this->discountSpecs['seasonal']->isSatisfiedBy(new \stdClass())) {
$available[] = $this->discountSpecs['seasonal']->getPromotionName();
}
if ($this->discountSpecs['first_time']->isSatisfiedBy($user)) {
$available[] = "First-time buyer: 10% off (up to $50)";
}
return $available;
}
}
Step 4: Middleware Integration
Create middleware to ensure only eligible users can access certain discount features:
<?php
namespace App\Http\Middleware;
use App\Specifications\Discount\MembershipDiscountSpecification;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireMembershipMiddleware
{
public function __construct(
private readonly MembershipDiscountSpecification $membershipSpec
) {}
public function handle(Request $request, Closure $next, string $tier = null): Response
{
$user = $request->user();
if (!$user) {
return redirect()->route('login');
}
$spec = $tier
? new MembershipDiscountSpecification($tier)
: $this->membershipSpec;
if (!$spec->isSatisfiedBy($user)) {
abort(403, 'This feature requires a valid membership.');
}
return $next($request);
}
}
Register in app/Http/Kernel.php
:
protected $middlewareAliases = [
// ... other middleware
'membership' => \App\Http\Middleware\RequireMembershipMiddleware::class,
];
Use in routes:
// Gold members only
Route::middleware(['auth', 'membership:gold'])
->get('/gold-exclusive-deals', [DealsController::class, 'goldDeals']);
// Any valid membership
Route::middleware(['auth', 'membership'])
->get('/member-discounts', [DealsController::class, 'memberDiscounts']);
Step 5: Policy Integration
Create policies for admin discount management:
<?php
namespace App\Policies;
use App\Models\User;
use App\Specifications\Discount\MembershipDiscountSpecification;
class DiscountPolicy
{
public function __construct(
private readonly MembershipDiscountSpecification $membershipSpec
) {}
public function viewDiscountAnalytics(User $user): bool
{
return $user->hasRole('admin') || $user->hasRole('manager');
}
public function manageSeasonalPromotions(User $user): bool
{
return $user->hasRole('admin');
}
public function applyManualDiscount(User $user, float $discountAmount): bool
{
if ($user->hasRole('admin')) {
return true;
}
// Managers can only apply limited discounts
if ($user->hasRole('manager')) {
return $discountAmount <= 100; // Max $100 manual discount
}
return false;
}
public function viewMemberDiscounts(User $user): bool
{
return $this->membershipSpec->isSatisfiedBy($user);
}
}
Step 6: Collection Integration
Process multiple orders efficiently:
<?php
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Collection;
class BulkDiscountService
{
public function __construct(
private readonly DiscountCalculatorService $discountCalculator
) {}
public function calculateBulkDiscounts(Collection $orders): Collection
{
return $orders->map(function (Order $order) {
$discountData = $this->discountCalculator->calculateTotalDiscount($order);
return [
'order_id' => $order->id,
'customer_name' => $order->customer->name,
'original_total' => $discountData['original_total'],
'total_discount' => $discountData['total_discount'],
'final_total' => $discountData['final_total'],
'discount_breakdown' => $discountData['discounts'],
];
});
}
public function getDiscountSummary(Collection $orders): array
{
$discountData = $this->calculateBulkDiscounts($orders);
return [
'total_orders' => $discountData->count(),
'total_original_amount' => $discountData->sum('original_total'),
'total_discount_amount' => $discountData->sum('total_discount'),
'total_final_amount' => $discountData->sum('final_total'),
'average_discount_rate' => $discountData->avg(function ($order) {
return $order['original_total'] > 0
? ($order['total_discount'] / $order['original_total']) * 100
: 0;
}),
'most_common_discounts' => $this->getMostCommonDiscounts($discountData),
];
}
private function getMostCommonDiscounts(Collection $discountData): array
{
$discountTypes = [];
foreach ($discountData as $order) {
foreach (array_keys($order['discount_breakdown']) as $type) {
$discountTypes[$type] = ($discountTypes[$type] ?? 0) + 1;
}
}
arsort($discountTypes);
return $discountTypes;
}
}
Step 7: Advanced Testing
<?php
namespace Tests\Feature;
use App\Models\Order;
use App\Models\User;
use App\Services\DiscountCalculatorService;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class DiscountCalculatorTest extends TestCase
{
use RefreshDatabase;
private DiscountCalculatorService $discountCalculator;
protected function setUp(): void
{
parent::setUp();
$this->discountCalculator = app(DiscountCalculatorService::class);
}
public function test_calculates_volume_discount_for_high_value_order(): void
{
// Arrange
$customer = User::factory()->create(['created_at' => now()->subYear()]);
$customer->orders()->createMany(Order::factory()->count(12)->make()->toArray());
$order = Order::factory()->create([
'customer_id' => $customer->id,
'subtotal' => 600.00,
]);
// Act
$result = $this->discountCalculator->calculateTotalDiscount($order);
// Assert
$this->assertEquals(90.00, $result['discounts']['volume']['amount']); // 15% of 600
$this->assertEquals(510.00, $result['final_total']);
}
public function test_applies_membership_and_seasonal_discounts_together(): void
{
// Arrange
$goldMember = User::factory()->create([
'membership_tier' => 'gold',
'membership_expires_at' => now()->addYear(),
]);
$order = Order::factory()->create([
'customer_id' => $goldMember->id,
'subtotal' => 300.00,
]);
// Mock seasonal promotion by creating service with mocked spec
$seasonalSpec = \Mockery::mock(\App\Specifications\Discount\SeasonalPromotionSpecification::class);
$seasonalSpec->shouldReceive('isSatisfiedBy')->andReturn(true);
$seasonalSpec->shouldReceive('getDiscountRate')->andReturn(0.12);
$seasonalSpec->shouldReceive('getPromotionName')->andReturn('Test Sale');
$this->app->instance(\App\Specifications\Discount\SeasonalPromotionSpecification::class, $seasonalSpec);
// Act
$result = $this->discountCalculator->calculateTotalDiscount($order);
// Assert
$expectedMembershipDiscount = 300 * 0.08; // 8% gold member
$expectedSeasonalDiscount = 300 * 0.12; // 12% seasonal
$this->assertEquals($expectedMembershipDiscount, $result['discounts']['membership']['amount']);
$this->assertEquals($expectedSeasonalDiscount, $result['discounts']['seasonal']['amount']);
}
public function test_respects_maximum_discount_cap(): void
{
// Arrange - Create scenario that would exceed 50% discount
$goldMember = User::factory()->create([
'membership_tier' => 'gold',
'membership_expires_at' => now()->addYear(),
'created_at' => now()->subYear(),
]);
$order = Order::factory()->create([
'customer_id' => $goldMember->id,
'subtotal' => 1000.00,
]);
// Act
$result = $this->discountCalculator->calculateTotalDiscount($order);
// Assert - Should be capped at 50%
$this->assertEquals(500.00, $result['total_discount']); // 50% of 1000
$this->assertEquals(500.00, $result['final_total']);
$this->assertTrue($result['discounts']['capped']);
}
}
Pest Tests for Specifications
<?php
use App\Models\User;
use App\Specifications\Discount\MembershipDiscountSpecification;
beforeEach(function () {
$this->specification = new MembershipDiscountSpecification();
});
it('validates gold membership correctly', function () {
$goldMember = User::factory()->create([
'membership_tier' => 'gold',
'membership_expires_at' => now()->addMonths(6),
]);
expect($this->specification->isSatisfiedBy($goldMember))->toBeTrue();
expect($this->specification->getDiscountRate($goldMember))->toBe(0.08);
});
it('rejects expired memberships', function () {
$expiredMember = User::factory()->create([
'membership_tier' => 'gold',
'membership_expires_at' => now()->subDay(),
]);
expect($this->specification->isSatisfiedBy($expiredMember))->toBeFalse();
expect($this->specification->getDiscountRate($expiredMember))->toBe(0.0);
});
it('can filter by specific membership tier', function () {
$goldSpec = new MembershipDiscountSpecification('gold');
$silverSpec = new MembershipDiscountSpecification('silver');
$goldMember = User::factory()->create([
'membership_tier' => 'gold',
'membership_expires_at' => now()->addYear(),
]);
expect($goldSpec->isSatisfiedBy($goldMember))->toBeTrue();
expect($silverSpec->isSatisfiedBy($goldMember))->toBeFalse();
});
Results: Powerful, Maintainable Discount System
Architecture Benefits
Before (Problems):
- ❌ 100+ lines of nested conditional logic
- ❌ Hardcoded seasonal dates
- ❌ Impossible to test individual rules
- ❌ No caching for expensive calculations
- ❌ Duplicate logic across methods
After (Benefits):
- ✅ Clean, focused specifications for each discount type
- ✅ Cacheable discount calculations
- ✅ Composable discount rules
- ✅ Middleware integration for access control
- ✅ Policy-based permission system
- ✅ Comprehensive test coverage
- ✅ Easy to add new discount types
Business Impact
// Adding a new discount type? Just create a new specification!
php artisan make:specification Discount/StudentDiscountSpecification
// Changing seasonal promotion dates? Update the configuration!
// No code changes needed in the business logic
// Want to A/B test different discount rates?
// Inject different specification instances - clean dependency injection!
This intermediate example demonstrates how specifications scale to handle complex business logic while maintaining clean architecture, performance optimization, and comprehensive integration with Laravel features.
Next: See how specifications handle enterprise-level complexity in our Advanced Examples.