ttpryg / promotion-engine
Framework-agnostic PHP 8.1+ Promotion, Coupon, and Discount Engine for E-commerce & Bookings
Requires
- php: ^8.1
- ext-pdo: *
- psr/event-dispatcher: ^1.0
- ttpryg/event-dispatcher: ^1.0
Requires (Dev)
- laravel/pint: ^1.20
- phpunit/phpunit: ^10.0
- rector/rector: ^2.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-31 05:52:59 UTC
README
ttpryg/promotion-engine is a framework-agnostic standalone PHP 8.1+ library for promotion, coupon code evaluation, automatic storewide discounts, and multi-store promo management.
📋 Requirements
- PHP
>= 8.1 ext-pdo— only required when using the PDO storage drivers- A PSR-14 event dispatcher — optional, e.g.
ttpryg/event-dispatcher
| PHP Version | Supported |
|---|---|
| 8.1 | ✅ |
| 8.2 | ✅ |
| 8.3 | ✅ |
| 8.4 | ✅ |
📦 Installation
composer require ttpryg/promotion-engine
🌟 Key Features
- Framework Agnostic: Works out of the box with any PHP 8.1+ application or framework (Vanilla PHP, Slim 4, Laravel, Symfony, CodeIgniter).
- Coupons & Automatic Promotions: Support for coupon code evaluations (
evaluateCoupon) and automatic storewide or global promotion claims (findBestAutomaticPromotion). - Flexible Discount Types:
FIXED_AMOUNT: Fixed currency discount (e.g. Rp 50.000 off).PERCENTAGE: Percentage discount (e.g. 20% off) with maximum cap limit (max_discount).
- Multi-Tenant / Multi-Store Scoping: Filter promotions by
store_idandowner_idfor multi-store marketplaces, with deterministic priority for store-scoped coupons over global ones sharing the same code. - Financial Precision: Discount amounts are always rounded to two decimals to avoid floating-point drift in money calculations.
- Usage & Eligibility Limits:
- Minimum spend thresholds (
min_spend). - Active date range windows (
start_at,end_at). - Total global usage limit (
usage_limit). - Per-user usage limit (
user_usage_limit).
- Minimum spend thresholds (
- Multiple Storage Drivers:
PdoPromotionRepository&PdoPromotionUsageRepository: Relational database storage via PDO (MariaDB, MySQL, SQLite).MemoryPromotionRepository&MemoryPromotionUsageRepository: In-memory storage for unit testing.
- Domain Validation & Exceptions: Guard clauses for percentage bounds (
0.01–100.00), non-negative amounts, valid date ranges, and per-store unique coupon codes, throwingInvalidPromotionException,PromotionNotFoundException, andPromotionUsageLimitExceededException— all catchable via the root marker interfaceExceptionInterface. - Safe Usage Recording: Atomic, limit-guarded
usage_countincrements wrapped in a database transaction to prevent overselling under concurrent checkouts. - Versioned Schema Migrations:
DatabaseMigratorbootstraps fresh installs fromdatabase/schema.sql, whileMigrationRunnerapplies incrementaldatabase/migrations/*.sqlchanges exactly once for existing databases. - Domain Events: Dispatches PSR-14 events (
PromotionCreatedEvent,PromotionAppliedEvent,PromotionUsedEvent).
🗄️ Database Schema
Run the SQL script from database/schema.sql:
CREATE TABLE IF NOT EXISTS promotions ( id VARCHAR(36) PRIMARY KEY, code VARCHAR(50) NULL, name VARCHAR(255) NOT NULL, description TEXT NULL, type VARCHAR(30) NOT NULL, value DECIMAL(12, 2) NOT NULL, store_id VARCHAR(36) NULL, owner_id VARCHAR(36) NULL, min_spend DECIMAL(12, 2) DEFAULT 0.00, max_discount DECIMAL(12, 2) NULL, usage_limit INT NULL, usage_count INT DEFAULT 0, user_usage_limit INT NULL, start_at DATETIME NULL, end_at DATETIME NULL, is_active TINYINT(1) DEFAULT 1, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS promotion_usages ( id VARCHAR(36) PRIMARY KEY, promotion_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL, cart_id VARCHAR(36) NULL, order_id VARCHAR(36) NULL, discount_amount DECIMAL(12, 2) NOT NULL, used_at DATETIME NOT NULL, FOREIGN KEY (promotion_id) REFERENCES promotions(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_promotions_code ON promotions(code); CREATE INDEX IF NOT EXISTS idx_promotions_store_active ON promotions(store_id, is_active); CREATE INDEX IF NOT EXISTS idx_usages_promo_user ON promotion_usages(promotion_id, user_id); CREATE INDEX IF NOT EXISTS idx_usages_order_id ON promotion_usages(order_id);
Versioned Migrations
database/schema.sql bootstraps fresh installs (every statement uses CREATE TABLE/INDEX IF NOT EXISTS, so re-running never touches existing data). For databases that already exist, the bundled migrator applies incremental SQL changes from database/migrations/*.sql exactly once, tracked in the promotion_engine_migrations table:
Fresh install — creates the full schema from database/schema.sql, then records every bundled migration as a baseline (never re-executed):
use Ttpryg\PromotionEngine\Database\DatabaseMigrator; (new DatabaseMigrator($pdo))->migrate();
Existing database — run pending migrations; each file executes once in name order, is recorded in the tracker only after success, and is automatically retried on the next run if it fails:
use Ttpryg\PromotionEngine\Database\MigrationRunner; $runner = new MigrationRunner($pdo); $runner->migrate(); // e.g. ['001_add_promotion_indexes'] $runner->appliedMigrations(); // full applied history
Migration
001_add_promotion_indexesupgrades databases created before the performance indexes existed. Fresh installs skip it automatically (baseline). MySQL DDL auto-commits, so keep migration files idempotent where possible.
🚀 Quick Usage Example
use PDO; use Ttpryg\PromotionEngine\Enums\PromotionType; use Ttpryg\PromotionEngine\Repositories\PdoPromotionRepository; use Ttpryg\PromotionEngine\Repositories\PdoPromotionUsageRepository; use Ttpryg\PromotionEngine\Services\PromotionService; use Ttpryg\PromotionEngine\Transactions\PdoTransactionManager; // 1. Initialize Database & Repositories $pdo = new PDO("mysql:host=localhost;dbname=my_db", "root", "secret"); $promotionRepo = new PdoPromotionRepository($pdo); $usageRepo = new PdoPromotionUsageRepository($pdo); $transactionManager = new PdoTransactionManager($pdo); $service = new PromotionService($promotionRepo, $usageRepo, transactionManager: $transactionManager); // 2. Create a Coupon Promotion $promotion = $service->createPromotion( id: 'promo-001', name: 'Diskon Merdeka 17%', type: PromotionType::PERCENTAGE, value: 17.0, code: 'MERDEKA17', storeId: 'store-100', minSpend: 100000.0, maxDiscount: 50000.0, userUsageLimit: 1 ); // 3. Evaluate Coupon in Shopping Cart $result = $service->evaluateCoupon('MERDEKA17', [ 'cart_subtotal' => 200000.0, 'user_id' => 'user-777', 'store_id' => 'store-100' ]); if ($result->isEligible) { echo "Potongan Diskon: Rp " . number_format($result->discountAmount, 0, ',', '.'); // Output: Potongan Diskon: Rp 34.000 } else { echo "Kupon Tidak Valid: " . $result->reason; } // 4. Record Usage upon Checkout $service->recordPromotionUsage( usageId: 'usage-999', promotionId: $promotion->id, userId: 'user-777', discountAmount: $result->discountAmount, orderId: 'order-888' );
🛡️ Error Handling
Every custom exception implements the root marker interface
Ttpryg\PromotionEngine\Exceptions\ExceptionInterface, so all library errors can
be handled with a single catch block:
use Ttpryg\PromotionEngine\Exceptions\ExceptionInterface; try { $service->recordPromotionUsage( usageId: 'usage-999', promotionId: $promotion->id, userId: 'user-777', discountAmount: $result->discountAmount ); } catch (ExceptionInterface $exception) { // InvalidPromotionException | PromotionNotFoundException // | PromotionUsageLimitExceededException error_log($exception->getMessage()); }
📄 License
MIT License.