Services
The plugin exposes four services: OrderLifecycleLogger for reading and writing log entries, StatsService for computing store-wide metrics, AiInsightsService for Claude prompt building and generation, and ExportService for CSV export - plus a stateless Formatter helper class used by the logger to build human-readable messages and labels.
OrderLifecycleLogger
Access via:
use johnhenry\orderlifecycle\OrderLifecycle;
$logger = OrderLifecycle::getInstance()->getLogger();log()
Logs an order lifecycle event, capturing a full snapshot and optionally auto-generating a change description.
Signature:
public function log(
Order $order,
EventType $type,
array $payload = [],
?string $message = null
): voidParameters:
| Parameter | Type | Description |
|---|---|---|
$order |
Order | The Craft Commerce order element |
$type |
EventType |
An EventType enum case |
$payload |
array |
Extra data stored in the snapshot's payload key |
$message |
string|null |
Custom message. If null, a message is auto-generated by diffing against the previous snapshot |
Example:
use johnhenry\orderlifecycle\OrderLifecycle;
use johnhenry\orderlifecycle\enums\EventType;
// Auto-generated message
OrderLifecycle::getInstance()->getLogger()->log(
$order,
EventType::CART_UPDATED
);
// Custom message with extra payload data
OrderLifecycle::getInstance()->getLogger()->log(
$order,
EventType::STATUS_CHANGED,
['triggeredBy' => 'webhook', 'source' => 'ERP'],
'Status updated via ERP sync'
);Throws: yii\db\Exception, yii\base\Exception, yii\base\InvalidConfigException
logCheckoutStarted()
Logs a checkoutStarted event for an order. Only records once per order - safe to call on every checkout page load.
Signature:
public function logCheckoutStarted(Order $order): boolReturns: true if the event was logged, false if already recorded for this order.
Example:
$logged = OrderLifecycle::getInstance()->getLogger()->logCheckoutStarted($cart);getLogsForOrder()
Retrieves all log entries for an order, newest first.
Signature:
public function getLogsForOrder(int $orderId): arrayReturns: Array of log rows, each containing:
| Key | Type | Description |
|---|---|---|
id | int | Log entry ID |
orderId | int | Order ID |
type | string |
Event type string (e.g. orderCompleted) |
message | string|null | Log message |
snapshot | string | JSON-encoded order snapshot |
userId | int|null | User who triggered the event |
ip | string|null | IP address (if collection enabled) |
dateCreated | string | ISO datetime string |
dateUpdated | string | ISO datetime string |
uid | string | GUID |
Example:
$logs = OrderLifecycle::getInstance()->getLogger()->getLogsForOrder($order->id);
foreach ($logs as $log) {
echo $log['type'] . ': ' . $log['message'] . PHP_EOL;
echo 'At: ' . $log['dateCreated'] . PHP_EOL;
}getTimelineForOrder()
Builds the enriched timeline used by the Order Lifecycle field - the same rows as getLogsForOrder(), minus aiInsights entries, with three extra keys computed fresh on every call (nothing here is cached or stored).
Signature:
public function getTimelineForOrder(int $orderId): arrayReturns: Array of log rows (see getLogsForOrder()), each with:
| Key | Type | Description |
|---|---|---|
pill |
string |
The filter pill this event shows under - see EventType::getFilterPill() |
deltaSeconds |
int|null |
Seconds since the previous chronological event; null for the oldest entry |
orderChanges |
array |
[['field' => ..., 'label' => ..., 'from' => ..., 'to' => ...], ...] for whichever order (or, for address events, address) fields changed since the previous event |
For address set/removed events, orderChanges is built from that event's own payload (before/after) rather than the generic order-snapshot diff, since the order-snapshot diff would otherwise show an empty result for the second address changed in the same save. The oldest event in the timeline diffs against an empty baseline, so it shows its initial field values as null -> value instead of an empty diff.
Example:
$timeline = OrderLifecycle::getInstance()->getLogger()->getTimelineForOrder($order->id);
foreach ($timeline as $row) {
echo "{$row['type']} ({$row['pill']}): " . count($row['orderChanges']) . " field(s) changed\n";
}getLastSnapshot()
Retrieves the decoded snapshot from the most recent log entry for an order.
Signature:
public function getLastSnapshot(int $orderId): ?arrayReturns: Decoded snapshot array or null if no logs exist.
Example:
$snapshot = OrderLifecycle::getInstance()->getLogger()->getLastSnapshot($order->id);
if ($snapshot) {
$previousTotal = $snapshot['order']['totalPrice'];
$previousStatus = $snapshot['order']['statusHandle'];
}getLastLogForOrderAndType()
Retrieves the most recent log entry of a specific type for an order.
Signature:
public function getLastLogForOrderAndType(int $orderId, EventType $type): ?arrayReturns: Log row array or null.
Example:
use johnhenry\orderlifecycle\enums\EventType;
$lastPayment = OrderLifecycle::getInstance()->getLogger()->getLastLogForOrderAndType(
$order->id,
EventType::PAYMENT_PROCESSED
);updateLog()
Updates fields on an existing log entry.
Signature:
public function updateLog(int $logId, array $updates): voidExample:
OrderLifecycle::getInstance()->getLogger()->updateLog($logId, [
'message' => 'Updated message after retry',
]);Snapshot Structure
Every log() call captures the full order state:
[
'order' => [
'id' => 123,
'number' => 'abc123def456',
'isCompleted' => true,
'dateOrdered' => '2026-07-05 16:00:46', // null until the order completes
'couponCode' => 'SAVE20',
'totalQty' => 3,
'totalPrice' => 99.99,
'totalShippingCost' => 4.99,
'currency' => 'EUR',
'statusId' => 2,
'statusHandle' => 'processing',
'shippingMethodHandle' => 'standardShipping',
'shippingMethodName' => 'Standard Shipping',
'shippingAddressId' => 456,
'billingAddressId' => 789,
],
'customer' => [
'email' => '[email protected]',
'isGuest' => false,
'customerId' => 789,
'userId' => 789,
],
'lineItems' => [
['sku' => 'PRODUCT-001', 'description' => 'Widget - Blue', 'qty' => 2, 'subtotal' => 49.98],
],
'discounts' => [], // discount adjustments
'addresses' => [
'billing' => [
'firstName' => 'Jane',
'lastName' => 'Doe',
'addressLine1' => '1 Main Street',
'addressLine2' => null,
'locality' => 'Dublin',
'administrativeArea' => null,
'postalCode' => 'D01ABC1',
'countryCode' => 'IE',
], // null if no billing address is set
'shipping' => [ /* same shape as billing */ ], // null if no shipping address is set
],
'payload' => [], // data passed to log() $payload parameter
]Country changes are detected from addresses.shipping.countryCode and addresses.billing.countryCode, not a flattened shippingCountry/billingCountry key.
Auto-Generated Change Messages
When $message is null, the logger diffs the current snapshot against the previous one and generates a description via generateChangeDescription(). Most event types build a list of individual field changes, joined with semicolons:
'Widget - Blue' added (qty: 2)
'Widget - Blue' removed (was qty: 1)
'Widget - Blue' quantity increased from 1 to 3
Total quantity changed from 2 to 3
Total price changed from 25.00 EUR to 49.99 EUR
Order status changed to 'processing'
Shipping method changed from Standard to Express
Shipping country changed to 'GB'
Email set to '[email protected]' (guest)Line item messages show the product/variant description (from the line item snapshot's description key), not the raw SKU.
A handful of event types get a single, fixed-format message instead of a field diff, since a plain description reads better than a list of raw field changes:
| Event type | Example message |
|---|---|
CART_CREATED |
Cart initialized for registered customer / Cart initialized for guest customer |
CHECKOUT_STARTED |
Registered customer visited checkout page / Guest customer visited checkout page |
ORDER_COMPLETED |
Cart converted to order |
ORDER_PAID |
€60.00 paid via Stripe |
PAYMENT_PROCESSED |
$60.00 via Dummy Gateway - succeeded |
COUPON_APPLIED / COUPON_REMOVED |
Coupon code 'SAVE20' applied / Coupon code 'SAVE20' removed |
EMAIL_SENT / EMAIL_FAILED |
Order Confirmation → [email protected] |
CART_CREATED and CHECKOUT_STARTED build their message directly at the log() call site (there's no previous snapshot yet for a brand-new cart, so generateChangeDescription() never runs for them) - see logOrderSaveChanges() and logCheckoutStarted().
Database Table
Logs are stored in the orderlifecycle_logs table (prefixed by Craft's table prefix):
| Column | Type | Notes |
|---|---|---|
id | INT PK | Auto-increment |
orderId | INT |
FK → commerce_orders.id |
type | VARCHAR(255) | EventType string value |
message | TEXT | |
snapshot | LONGTEXT | JSON |
userId | INT |
FK → users.id, nullable |
ip | VARCHAR(45) | IPv4/IPv6, nullable |
dateCreated | DATETIME | |
dateUpdated | DATETIME | |
uid | CHAR(36) |
Indexes: (orderId, dateCreated), dateCreated, userId, type.
Best Practices
- Use
EventTypeenum cases - don't pass raw strings tolog() - Let auto-detection handle messages - only pass
$messagewhen you need a specific custom description - Include relevant payload data - add context that will be useful for debugging or reporting
- Wrap in try/catch if logging must not block - log failures shouldn't interrupt the main order flow
try {
OrderLifecycle::getInstance()->getLogger()->log($order, EventType::CART_UPDATED);
} catch (\Throwable $e) {
Craft::error('Failed to log order lifecycle: ' . $e->getMessage(), 'order-lifecycle');
}Formatter Helper
johnhenry\orderlifecycle\helpers\Formatter is a stateless helper of static methods used by the logger to build change messages and diff-table labels. It's not a plugin component - use it directly by its fully-qualified class name.
| Method | Description |
|---|---|
orderFieldLabel(string $field): string |
Human-readable label for a top-level order snapshot field (e.g. shippingMethodHandle → Shipping Method). Falls back to a humanised version of the field name for anything unmapped |
addressFieldLabel(string $field): string |
Human-readable label for an address snapshot field (e.g. addressLine1 → Address) |
addressChange(?array $from, ?array $to, string $type = 'shipping'): ?string |
Builds an address change message - a plain address for a first-time set, a field-by-field diff for a change, or null for a removal (the event title already says that) |
compareLineItems(array $previous, array $current): array | Builds a list of line item change messages, matched by SKU and labeled with the line item's description |
couponChange(?string $from, ?string $to): ?string | Builds a coupon applied/removed message |
currency(float $amount, string $currency): string / dateTime(mixed $value, string $format = 'medium'): string | Thin wrappers around Craft's formatter component |
sanitize(string $text): string | HTML-escapes customer-supplied text before it goes into a message or the timeline display |
use johnhenry\orderlifecycle\helpers\Formatter;
$label = Formatter::orderFieldLabel('totalShippingCost'); // 'Shipping Cost'StatsService
The StatsService computes store-wide aggregate metrics from the orderlifecycle_logs table. It powers both the Overview dashboard page and the Order Lifecycle Stats widget.
Access via:
use johnhenry\orderlifecycle\OrderLifecycle;
$statsService = OrderLifecycle::getInstance()->getStats();getStats()
Returns an array of store metrics for the specified number of days.
Signature:
public function getStats(int $days): arrayReturns:
| Key | Type | Description |
|---|---|---|
totalLogs | int | Total log entries in the period |
uniqueOrders | int | Orders with at least one log entry |
avgLogsPerOrder | float | Average events per order |
topEventTypes | array |
Top 5 event types by count ([['type' => ..., 'count' => ...], ...]) |
avgTimeToCompletion | string|null |
Average time from cartCreated to orderCompleted (e.g. "2h", "45m") |
conversionRate | float | Percentage of carts that completed |
cartsCreated | int |
Unique orders with a cartCreated event |
ordersCompleted | int |
Unique orders with an orderCompleted event |
avgCheckoutDuration | string|null |
Average time from checkoutStarted to orderPaid |
abandonmentRate | float | Percentage of carts that did not complete |
abandonedCarts | int | Count of abandoned carts |
avgPaymentAttempts | float |
Average paymentAttempt events per completed order |
returningCustomerRate | float | Percentage of completed orders from registered customers |
avgCartValue | float|null | Mean total price of completed orders |
emailSuccessRate | float | Email success rate as a percentage |
emailsSent | int |
Total emailSent events |
emailsFailed | int |
Total emailFailed events |
days | int |
The $days parameter echoed back |
Example:
$stats = OrderLifecycle::getInstance()->getStats()->getStats(30);
echo "Conversion rate: {$stats['conversionRate']}%\n";
echo "Average cart value: {$stats['avgCartValue']}\n";
echo "Email success rate: {$stats['emailSuccessRate']}%\n";AiInsightsService
The AiInsightsService owns Claude prompt building, the Anthropic API call, and result persistence for both per-order and store-wide AI insights. See the AI Insights Guide for user-facing behaviour.
Access via:
use johnhenry\orderlifecycle\OrderLifecycle;
$ai = OrderLifecycle::getInstance()->getAiInsights();Key methods:
| Method | Description |
|---|---|
getApiKey(): string | Resolves the configured Anthropic API key from settings/env; empty string if unset |
buildOrderPrompt(array $context): string | Builds the per-order prompt; wraps the event timeline as untrusted data |
buildStorePrompt(array $stats, int $days, string $context = ''): string |
Builds the store-wide prompt; truncates and wraps free-text $context as untrusted data |
truncateContext(string $context): string | Bounds free-text context to a fixed length |
generateInsights(string $apiKey, string $prompt): string | Makes the Anthropic API call and returns the response text |
decodeStructuredInsights(?string $raw): ?array |
Parses a per-order insights response into ['priorityAction' => ..., 'items' => [...]]. Returns null if the text isn't valid structured JSON (e.g. a narrative-style response), so callers can fall back to rendering it as markdown |
structuredOutputInstruction(): string | Static. Returns the instruction text appended to the per-order prompt for the structured style |
saveStoreInsights(string $insights, int $days): array |
Persists store-wide insights to cache; returns the saved record (insights, days, generatedAt) |
getSavedStoreInsights(): ?array |
Retrieves the cached store-wide insights, or null if none exist |
Store-wide generation runs on the queue (GenerateStoreInsights job) rather than synchronously in the request - see AiController::actionStoreInsights() / actionStoreInsightsStatus() for the queue-and-poll pattern. Per-order generation (AiController::actionInsights()) still runs synchronously.
decodeStructuredInsights() is what lets the Order Lifecycle field render structured-style insights as labeled cards and fall back to markdown for narrative-style ones, regardless of which style is currently configured - the decision is made by trying to parse the saved text, not by trusting the setting (which may have changed since the insight was generated). See the AI Insights guide.
ExportService
The ExportService builds CSV exports from log rows, batching order lookups to avoid N+1 queries.
Access via:
use johnhenry\orderlifecycle\OrderLifecycle;
$export = OrderLifecycle::getInstance()->getExport();generateCsv()
Signature:
public function generateCsv(array $logs, array $columns = []): stringParameters:
| Parameter | Type | Description |
|---|---|---|
$logs |
array |
Log rows, as returned by OrderLifecycleLogger::getLogsForOrder() or a custom query |
$columns |
array | Optional column allowlist; defaults to all standard columns |
Returns: The generated CSV content as a string.
See Also
- Events API - All EventType cases and what triggers them
- Timeline View Guide - Viewing logs in the control panel
- Statistics Guide - Analysing logged data
- Export Guide - Exporting events to CSV
- AI Insights Guide - AI-powered order and store analysis