ttpryg / order-engine
Framework-agnostic PHP Order Engine library for order lifecycle, snapshots, calculations, address management, and order history.
Requires
- php: ^8.1
- ttpryg/event-dispatcher: ^1.0
Requires (Dev)
- laravel/pint: ^1.20
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.0
- rector/rector: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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_historiesaudit entry commit or roll back together). - Optimistic Locking: Every order carries a
versioncolumn; updates useWHERE id = :id AND version = :current_versionand throwConcurrencyExceptionon lost-update collisions (e.g. webhook confirming an order while an admin cancels it). - Money-Safe Calculations: All monetary values are represented by the
Moneyvalue 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_atranges, and a payment view derived from the order status (no payment column stored). - Versioned Schema Migrations:
MigrationRunnerwith anorder_engine_migrationstracker table โ incremental SQL migrations run once, are recorded only after success, and resume safely after failures;DatabaseMigratorbootstraps fresh installs fromdatabase/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:OrderStatusEnum (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 at1, incremented on every persisted update).created_at&updated_at:DateTimeImmutabletimestamps.
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,skuname: Snapshot of product nameunit_price: Snapshot of unit price (Money)quantity,discount_total,tax_total,subtotal(monetary fields areMoney)
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:OrderAddressTypeEnum (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:
OrderCreatedEventOrderStatusChangedEventOrderConfirmedEventOrderProcessingEventOrderCompletedEventOrderCancelledEventOrderRefundedEvent
๐ 11. Repository Abstraction
Repository interfaces are decoupled in Ttpryg\OrderEngine\Contracts:
OrderRepositoryInterfaceOrderItemRepositoryInterfaceOrderAddressRepositoryInterfaceOrderHistoryRepositoryInterface
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 UPDATEon MySQL/MariaDB,INSERT ... ON CONFLICT (id) DO UPDATEon SQLite/PostgreSQL โ andOrderItemRepositoryInterface::saveMany()persists many items in one bulk statement (used byOrderService::createOrder()). - Orders run the version-guarded
UPDATE ... WHERE id = :id AND version = :versionfirst (one statement for existing orders), then a plainINSERTwhen no row matched; theSELECTthat 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;
nullproperties are ignored. Setting bothstatusandpaymentStatusapplies both independently (a contradictory combination simply yields an empty result). PaymentStatusis derived fromOrderStatusโ this library stores no payment column (zero cross-domain dependencies):UNPAID= pending/cancelled,PAID= confirmed/processing/completed,REFUNDED= refunded.pageandperPageare normalized on every driver (pageโฅ 1,perPage1โ100), so misbehaving input can never produce unbounded scans.MemoryOrderRepositoryandPdoOrderRepositorybehave 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_versionupgrades 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.