Skip to content

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
<?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:

  1. Volume Discounts: Based on order total and customer loyalty
  2. Membership Tiers: Gold (8%) and Silver (5%) with special rules
  3. Category Bonuses: Electronics bonus for gold members, Sunday book discounts
  4. Seasonal Promotions: Black Friday, Independence Day, etc.
  5. First-Time Buyer: 10% up to $50 for new customers
  6. Referral Program: 5% up to $25 for recent referrals
  7. 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

bash
# 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
<?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
<?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
<?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
<?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
<?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:

php
protected $middlewareAliases = [
    // ... other middleware
    'membership' => \App\Http\Middleware\RequireMembershipMiddleware::class,
];

Use in routes:

php
// 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
<?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
<?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
<?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
<?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

php
// 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.

View Advanced Examples →

Released under the MIT License.