Search by

ttpryg / order-engine

totoprayogo1916

Framework-agnostic PHP Order Engine library for order lifecycle, snapshots, calculations, address management, and order history.

Package info

github.com/ttpryg/order-engine

pkg:composer/ttpryg/order-engine

Statistics

Installs: 259

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.1.0 2026-10-17 15:19 UTC

This package is auto-updated.

Last update: 2026-10-18 05:11:35 UTC


README

ttpryg/order-engine is a framework-agnostic standalone PHP 8.1+ Order Engine library designed to handle order lifecycle management, product snapshot preservation, address management, append-only order history, domain events, order calculations, and PDO persistence cleanly without coupling to any specific framework or external domain.

๐ŸŒŸ 1. Introduction

In modern ecommerce architectures, managing orders requires strict isolation of domain boundaries. An Order Engine must be responsible for storing historical order snapshots, calculating totals, enforcing valid status transitions, and recording audit histories without directly depending on inventory, payment gateways, promotion engines, or customer services.

ttpryg/order-engine provides a pure domain model and repository abstraction that can be seamlessly integrated into Vanilla PHP, Slim, Laravel, Symfony, CodeIgniter, or microservices.

๐Ÿš€ 2. Features

  • Framework Agnostic: Pure PHP 8.1+ with zero framework dependencies.
  • Strict Domain Boundaries: Uses generic references (customer_type / customer_id, reference_type / reference_id) instead of foreign key dependencies.
  • Product & Address Snapshotting: Immutable historical data preservation at checkout time (name, sku, unit_price, shipping/billing addresses).
  • Strict Order Lifecycle: Enforces valid status state machine transitions (pending, confirmed, processing, completed, cancelled, refunded).
  • Idempotent Operations: Calling status transitions on already-matched targets avoids duplicate persistence or redundant events.
  • Append-Only History: Immutable audit log for every important order action (order_created, status_changed, item_added, item_removed, item_quantity_changed, address_changed).
  • Domain Events: Dispatches PSR-14 events (OrderCreatedEvent, OrderStatusChangedEvent, OrderConfirmedEvent, OrderProcessingEvent, OrderCompletedEvent, OrderCancelledEvent, OrderRefundedEvent).
  • Transaction Safety: Atomic database transactions for order creation/mutation and for status transitions (the order update and its order_histories audit entry commit or roll back together).
  • Optimistic Locking: Every order carries a version column; updates use WHERE id = :id AND version = :current_version and throw ConcurrencyException on lost-update collisions (e.g. webhook confirming an order while an admin cancels it).
  • Money-Safe Calculations: All monetary values are represented by the Money value object using integer minor units (no floating-point arithmetic), safe for payment gateway integration and financial audits.
  • Multiple Storage Drivers: Clean separation via PDO (MariaDB, MySQL, SQLite) & In-Memory drivers for ultra-fast testing.
  • Paginated Customer History: paginateByCustomer() with deterministic ordering and filters for lifecycle status, created_at ranges, and a payment view derived from the order status (no payment column stored).
  • Versioned Schema Migrations: MigrationRunner with an order_engine_migrations tracker table โ€” incremental SQL migrations run once, are recorded only after success, and resume safely after failures; DatabaseMigrator bootstraps fresh installs from database/schema.sql.

๐Ÿ“ฆ 3. Installation

Install via Composer:

composer require ttpryg/order-engine

๐Ÿงฌ 4. Domain Model

The core entity is Order:

  • id: Unique identifier (UUID/string).
  • order_number: Unique human-readable reference (e.g. ORD-20260922-A1B2C3).
  • customer_type: Generic customer reference type (e.g., user, member, guest).
  • customer_id: Generic customer ID.
  • status: OrderStatus Enum (pending, confirmed, processing, completed, cancelled, refunded).
  • currency: Currency code (default: IDR).
  • subtotal: Total of item subtotals (Money).
  • discount_total: Total order discount applied (Money).
  • tax_total: Total order tax applied (Money).
  • shipping_total: Total shipping fee (Money).
  • grand_total: Final amount (subtotal - discount_total + tax_total + shipping_total, Money).
  • note: Customer or system note.
  • version: Optimistic-locking counter (INT, starts at 1, incremented on every persisted update).
  • created_at & updated_at: DateTimeImmutable timestamps.

Calculation properties are encapsulated: subtotal, discount_total, tax_total, shipping_total, and grand_total on Order (plus quantity, unit_price, discount_total, tax_total, and subtotal on OrderItem) are private and read through getters ($order->grandTotal(), $item->quantity(), โ€ฆ). They are only produced by recalculateTotals() / the calculation service, and quantity can never be zero or negative โ€” OrderItem rejects < 1 at construction and in updateQuantity(), so the invalid state cannot exist.

๐Ÿ”„ 5. Order Lifecycle

Order status transitions follow a strict state machine:

PENDING โ”€โ”€> CONFIRMED โ”€โ”€> PROCESSING โ”€โ”€> COMPLETED โ”€โ”€> REFUNDED
   โ”‚             โ”‚             โ”‚
   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€> CANCELLED

Invalid transitions (e.g., PENDING directly to COMPLETED or CANCELLED to CONFIRMED) throw an InvalidStatusTransitionException.

๐Ÿ›’ 6. Order Items

OrderItem captures a snapshot of the product at checkout:

  • id, order_id, product_id, variant_id, sku
  • name: Snapshot of product name
  • unit_price: Snapshot of unit price (Money)
  • quantity, discount_total, tax_total, subtotal (monetary fields are Money)

If product price or title changes in the catalog later, historical order items remain unaffected.

๐Ÿ“ฌ 7. Address

OrderAddress stores snapshot information for billing and shipping addresses:

  • type: OrderAddressType Enum (billing, shipping).
  • name, phone, address, city, province, postal_code, country.

๐Ÿงฎ 8. Calculation

Order calculations are managed by OrderCalculationService:

item_subtotal = max(0, (quantity ร— unit_price) - discount_total + tax_total)
grand_total   = max(0, subtotal - discount_total + tax_total + shipping_total)

All calculations run on the Money value object (integer minor units), so results are exact and never suffer from floating-point drift:

use Ttpryg\OrderEngine\ValueObjects\Money;

$unitPrice = Money::fromDecimal(15000000);   // Rp 15.000.000,00
$total     = $unitPrice->multiply(2);        // Rp 30.000.000,00

$unitPrice->toDecimal();   // "15000000.00" โ€” untuk kolom DECIMAL / payment gateway
$unitPrice->minorUnits();  // 1500000000
$unitPrice->toFloat();     // 15000000.0 โ€” hanya untuk display/logging

Money::fromDecimal() accepts Money, int, float, and string inputs; inputs are normalized immediately into exact integer minor units. Mixed-currency arithmetic throws an InvalidArgumentException.

The service is the single source of truth for every formula: Order::recalculateTotals() and each item's $subtotal refresh delegate to it, so the math lives in exactly one place. To customize the rules (custom rounding, service fees, tax strategy), subclass it and inject one instance:

use Ttpryg\OrderEngine\Services\OrderCalculationService;
use Ttpryg\OrderEngine\Services\OrderService;

class MyCalculationService extends OrderCalculationService
{
    // override calculateItemSubtotal() and/or calculateGrandTotal()
}

// Application-level: injected into every order the service creates or hydrates
$orderService = new OrderService(
    $orderRepo,
    $itemRepo,
    $addressRepo,
    $historyRepo,
    calculationService: new MyCalculationService
);

// Per-order: swap and recalculate
$order->setCalculationService(new MyCalculationService)->recalculateTotals();

๐Ÿ“œ 9. Order History

Every change appends an immutable entry to order_histories:

  • action: OrderHistoryAction (order_created, status_changed, item_added, item_removed, item_quantity_changed, address_changed).
  • from_status, to_status, reference_type, reference_id, note, actor_id, created_at.

Histories are append-only; old records are never mutated or updated.

๐Ÿ“ข 10. Events

The package emits the following PSR-14 events:

  • OrderCreatedEvent
  • OrderStatusChangedEvent
  • OrderConfirmedEvent
  • OrderProcessingEvent
  • OrderCompletedEvent
  • OrderCancelledEvent
  • OrderRefundedEvent

๐Ÿ“‚ 11. Repository Abstraction

Repository interfaces are decoupled in Ttpryg\OrderEngine\Contracts:

  • OrderRepositoryInterface
  • OrderItemRepositoryInterface
  • OrderAddressRepositoryInterface
  • OrderHistoryRepositoryInterface

Both In-Memory (MemoryOrderRepository, etc.) and PDO implementations (PdoOrderRepository, etc.) are provided.

PDO repositories never perform a SELECT before writing (no "SELECT before INSERT/UPDATE" pattern):

  • Order items are written with a single upsert statement โ€” INSERT ... ON DUPLICATE KEY UPDATE on MySQL/MariaDB, INSERT ... ON CONFLICT (id) DO UPDATE on SQLite/PostgreSQL โ€” and OrderItemRepositoryInterface::saveMany() persists many items in one bulk statement (used by OrderService::createOrder()).
  • Orders run the version-guarded UPDATE ... WHERE id = :id AND version = :version first (one statement for existing orders), then a plain INSERT when no row matched; the SELECT that still exists is only used to classify an INSERT failure (stale version โ†’ ConcurrencyException).

Querying & Pagination

OrderRepositoryInterface::paginateByCustomer() returns one page of a customer's orders (newest first, created_at DESC with id DESC as tie-breaker) together with metadata:

use Ttpryg\OrderEngine\Enums\OrderStatus;
use Ttpryg\OrderEngine\Enums\PaymentStatus;
use Ttpryg\OrderEngine\ValueObjects\OrderFilter;

$result = $orderRepo->paginateByCustomer(
    'user',
    'user-123',
    page: 1,
    perPage: 20,
    filter: new OrderFilter(
        status: OrderStatus::PENDING,          // lifecycle status
        createdFrom: new DateTimeImmutable('2026-01-01'),
        createdTo: new DateTimeImmutable('2026-01-31'),
        paymentStatus: PaymentStatus::UNPAID,  // derived from status
    )
);

$result->items;      // Order[] for this page
$result->total;      // total matching rows across all pages
$result->totalPages; // 0 when empty
$result->hasMore();  // page < totalPages

Details:

  • All filter conditions are combined with AND semantics; null properties are ignored. Setting both status and paymentStatus applies both independently (a contradictory combination simply yields an empty result).
  • PaymentStatus is derived from OrderStatus โ€” this library stores no payment column (zero cross-domain dependencies): UNPAID = pending/cancelled, PAID = confirmed/processing/completed, REFUNDED = refunded.
  • page and perPage are normalized on every driver (page โ‰ฅ 1, perPage 1โ€“100), so misbehaving input can never produce unbounded scans.
  • MemoryOrderRepository and PdoOrderRepository behave identically (covered by parity tests).

๐Ÿ›ข๏ธ 12. PDO Usage

use PDO;
use Ttpryg\OrderEngine\Repositories\PdoOrderRepository;
use Ttpryg\OrderEngine\Repositories\PdoOrderItemRepository;
use Ttpryg\OrderEngine\Repositories\PdoOrderAddressRepository;
use Ttpryg\OrderEngine\Repositories\PdoOrderHistoryRepository;
use Ttpryg\OrderEngine\Services\OrderService;
use Ttpryg\OrderEngine\Services\OrderStatusService;

$pdo = new PDO("mysql:host=127.0.0.1;dbname=order_db", "root", "secret");

$orderRepo   = new PdoOrderRepository($pdo);
$itemRepo    = new PdoOrderItemRepository($pdo);
$addressRepo = new PdoOrderAddressRepository($pdo);
$historyRepo = new PdoOrderHistoryRepository($pdo);

$orderService  = new OrderService($orderRepo, $itemRepo, $addressRepo, $historyRepo, pdo: $pdo);
$statusService = new OrderStatusService($orderRepo, $historyRepo, pdo: $pdo);

When a PDO instance is provided, both services run their persistence work inside a database transaction. For OrderStatusService, updating the order status and writing the order_histories audit record are atomic โ€” if the history write fails, the status update is rolled back. If you pass no PDO (e.g. with in-memory repositories in tests), operations run without transaction management.

PdoOrderRepository::save() uses optimistic locking: it only updates the row while the in-memory version still matches the database (UPDATE ... WHERE id = :id AND version = :version). If another process saved in the meantime, a Ttpryg\OrderEngine\Exceptions\ConcurrencyException is thrown and no lost update occurs. Reload the order (e.g. via OrderService::getOrder()) to obtain the latest version before retrying.

๐Ÿ—„๏ธ 13. Database Schema

Run database/schema.sql:

CREATE TABLE IF NOT EXISTS orders (
    id VARCHAR(36) PRIMARY KEY,
    order_number VARCHAR(50) NOT NULL UNIQUE,
    customer_type VARCHAR(50) NOT NULL,
    customer_id VARCHAR(100) NOT NULL,
    status VARCHAR(30) NOT NULL,
    currency VARCHAR(3) NOT NULL DEFAULT 'IDR',
    subtotal DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    discount_total DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    tax_total DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    shipping_total DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    grand_total DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    note TEXT NULL,
    version INT NOT NULL DEFAULT 1,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders (customer_type, customer_id);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status);
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders (created_at);

CREATE TABLE IF NOT EXISTS order_items (
    id VARCHAR(36) PRIMARY KEY,
    order_id VARCHAR(36) NOT NULL,
    product_id VARCHAR(36) NOT NULL,
    variant_id VARCHAR(36) NULL,
    sku VARCHAR(100) NULL,
    name VARCHAR(255) NOT NULL,
    quantity INT NOT NULL,
    unit_price DECIMAL(18,2) NOT NULL,
    discount_total DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    tax_total DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    subtotal DECIMAL(18,2) NOT NULL DEFAULT 0.00,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items (order_id);

CREATE TABLE IF NOT EXISTS order_addresses (
    id VARCHAR(36) PRIMARY KEY,
    order_id VARCHAR(36) NOT NULL,
    type VARCHAR(30) NOT NULL,
    name VARCHAR(150) NOT NULL,
    phone VARCHAR(50) NOT NULL,
    address TEXT NOT NULL,
    city VARCHAR(100) NOT NULL,
    province VARCHAR(100) NOT NULL,
    postal_code VARCHAR(20) NOT NULL,
    country VARCHAR(100) NOT NULL DEFAULT 'ID',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_order_addresses_order_id ON order_addresses (order_id);
CREATE INDEX IF NOT EXISTS idx_order_addresses_type ON order_addresses (order_id, type);

CREATE TABLE IF NOT EXISTS order_histories (
    id VARCHAR(36) PRIMARY KEY,
    order_id VARCHAR(36) NOT NULL,
    action VARCHAR(50) NOT NULL,
    from_status VARCHAR(30) NULL,
    to_status VARCHAR(30) NULL,
    reference_type VARCHAR(50) NULL,
    reference_id VARCHAR(100) NULL,
    note TEXT NULL,
    actor_id VARCHAR(36) NULL,
    created_at DATETIME NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_order_histories_order_id ON order_histories (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 order_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\OrderEngine\Database\DatabaseMigrator;

(new DatabaseMigrator($pdo))->migrate();

Existing database (e.g. created before the orders.version optimistic-locking column existed) โ€” 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\OrderEngine\Database\MigrationRunner;

$runner = new MigrationRunner($pdo);
$runner->migrate();            // e.g. ['001_add_orders_version']
$runner->appliedMigrations();  // full applied history

Migration 001_add_orders_version upgrades databases created before optimistic locking existed. Fresh installs skip it automatically (baseline). MySQL DDL auto-commits, so keep migration files idempotent where possible; the tracker prevents any re-execution after success.

๐Ÿงช 14. Testing

Run PHPUnit test suite:

vendor/bin/phpunit

Or run the full quality gate (tests, Pint, Rector dry-run, PHPStan level 6 โ€” zero errors):

composer check

Or using Docker Compose (this package is a library โ€” Docker only runs the PHP CLI tooling container for tests/linting, no web server):

docker compose up -d
docker compose exec app composer check

๐Ÿ’ก 15. Framework Integration Example

Slim 4 / Vanilla PHP / Laravel Integration

use Ttpryg\OrderEngine\Entities\OrderItem;
use Ttpryg\OrderEngine\Entities\OrderAddress;
use Ttpryg\OrderEngine\Enums\OrderAddressType;
use Ttpryg\OrderEngine\ValueObjects\Money;

// 1. Create a new Order
$item = new OrderItem(
    id: 'item-001',
    orderId: 'order-1001',
    productId: 'prod-88',
    sku: 'SKU-LAPTOP-01',
    name: 'Pro Laptop 15-inch',
    quantity: 1,
    unitPrice: Money::fromDecimal(15000000)
);

$shippingAddress = new OrderAddress(
    id: 'addr-001',
    orderId: 'order-1001',
    type: OrderAddressType::SHIPPING,
    name: 'Budi Santoso',
    phone: '081234567890',
    address: 'Jl. Sudirman No. 45',
    city: 'Jakarta Selatan',
    province: 'DKI Jakarta',
    postalCode: '12190'
);

$order = $orderService->createOrder(
    id: 'order-1001',
    customerType: 'user',
    customerId: 'usr-55',
    items: [$item],
    addresses: [$shippingAddress],
    shippingTotal: Money::fromDecimal(50000)
);

// Read the exact grand total (e.g. for a payment gateway invoice)
$paymentAmount = $order->grandTotal()->toDecimal(); // "15050000.00"

// 2. Advance Order Lifecycle Status
$statusService->confirm('order-1001', actorId: 'admin-1');
$statusService->process('order-1001', actorId: 'system');
$statusService->complete('order-1001', actorId: 'system');

๐ŸŽฏ 16. Design Principles

  • Single Responsibility: Manages solely order lifecycle, snapshots, history, and calculations.
  • Zero Cross-Domain Dependencies: Does not reserve stock, execute payment processing, generate invoices, or handle shipping logistics directly.
  • Append-Only Auditing: Every status change or item modification appends to immutable history records.
  • Idempotency: Prevents invalid duplicate state modifications.

โš ๏ธ 17. Limitations

  • Does not calculate promotional rules, coupons, or cart discounts automatically; discount values are received as values from external application layers.
  • Does not interact with payment gateways or shipping couriers.

๐Ÿ“„ 18. License

MIT License.