Search by

ttpryg / promotion-engine

totoprayogo1916

Framework-agnostic PHP 8.1+ Promotion, Coupon, and Discount Engine for E-commerce & Bookings

Package info

github.com/ttpryg/promotion-engine

pkg:composer/ttpryg/promotion-engine

Statistics

Installs: 346

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 2

1.1.0 2026-10-18 04:10 UTC

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_id and owner_id for 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).
  • 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, throwing InvalidPromotionException, PromotionNotFoundException, and PromotionUsageLimitExceededException — all catchable via the root marker interface ExceptionInterface.
  • Safe Usage Recording: Atomic, limit-guarded usage_count increments wrapped in a database transaction to prevent overselling under concurrent checkouts.
  • Versioned Schema Migrations: DatabaseMigrator bootstraps fresh installs from database/schema.sql, while MigrationRunner applies incremental database/migrations/*.sql changes 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_indexes upgrades 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.