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
): void

Parameters:

ParameterTypeDescription
$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): bool

Returns: 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): array

Returns: Array of log rows, each containing:

KeyTypeDescription
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): array

Returns: Array of log rows (see getLogsForOrder()), each with:

KeyTypeDescription
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): ?array

Returns: 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): ?array

Returns: 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): void

Example:

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 typeExample 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):

ColumnTypeNotes
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

  1. Use EventType enum cases - don't pass raw strings to log()
  2. Let auto-detection handle messages - only pass $message when you need a specific custom description
  3. Include relevant payload data - add context that will be useful for debugging or reporting
  4. 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.

MethodDescription
orderFieldLabel(string $field): string Human-readable label for a top-level order snapshot field (e.g. shippingMethodHandleShipping 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. addressLine1Address)
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): array

Returns:

KeyTypeDescription
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:

MethodDescription
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 = []): string

Parameters:

ParameterTypeDescription
$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