---
title: Services
date: 2026-06-13T16:17:00+01:00
author: John Henry Donovan
canonical_url: "https://johnhenry.ie/plugins/order-lifecycle/docs/api/services"
section: Plugins
---
esc

↑↓ to navigate↵ to selectesc to close

Menu On this page 

- [Return to top](#)

Documentation

Documentation

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

```php
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:**

```php
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:**

```php
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:**

```php
public function logCheckoutStarted(Order $order): bool
```

**Returns:** `true` if the event was logged, `false` if already recorded for this order.

**Example:**

```php
$logged = OrderLifecycle::getInstance()->getLogger()->logCheckoutStarted($cart);
```

### `getLogsForOrder()`

Retrieves all log entries for an order, newest first.

**Signature:**

```php
public function getLogsForOrder(int $orderId): array
```

**Returns:** Array of log rows, each containing:

KeyTypeDescription`id`intLog entry ID`orderId`intOrder ID`type`stringEvent type string (e.g. `orderCompleted)``message`string|nullLog message`snapshot`stringJSON-encoded order snapshot`userId`int|nullUser who triggered the event`ip`string|nullIP address (if collection enabled)`dateCreated`stringISO datetime string`dateUpdated`stringISO datetime string`uid`stringGUID

**Example:**

```php
$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:**

```php
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:**

```php
$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:**

```php
public function getLastSnapshot(int $orderId): ?array
```

**Returns:** Decoded snapshot array or `null` if no logs exist.

**Example:**

```php
$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:**

```php
public function getLastLogForOrderAndType(int $orderId, EventType $type): ?array
```

**Returns:** Log row array or `null`.

**Example:**

```php
use johnhenry\orderlifecycle\enums\EventType;

$lastPayment = OrderLifecycle::getInstance()->getLogger()->getLastLogForOrderAndType(
    $order->id,
    EventType::PAYMENT_PROCESSED
);
```

### `updateLog()`

Updates fields on an existing log entry.

**Signature:**

```php
public function updateLog(int $logId, array $updates): void
```

**Example:**

```php
OrderLifecycle::getInstance()->getLogger()->updateLog($logId, [
    'message' => 'Updated message after retry',
]);
```

## Snapshot Structure

Every `log()` call captures the full order state:

```php
[
    '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'      => 'customer@example.com',
        '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:

```twig
'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 'user@example.com' (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 → jane@example.com`

`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 PKAuto-increment`orderId`INTFK → `commerce_orders.id``type`VARCHAR(255)EventType string value`message`TEXT`snapshot`LONGTEXTJSON`userId`INTFK → `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** `<strong>EventType</strong>` **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

```php
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. `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

```php
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:

```php
use johnhenry\orderlifecycle\OrderLifecycle;

$statsService = OrderLifecycle::getInstance()->getStats();
```

### `getStats()`

Returns an array of store metrics for the specified number of days.

**Signature:**

```php
public function getStats(int $days): array
```

**Returns:**

KeyTypeDescription`totalLogs`intTotal log entries in the period`uniqueOrders`intOrders with at least one log entry`avgLogsPerOrder`floatAverage events per order`topEventTypes`arrayTop 5 event types by count (`[['type' => ..., 'count' => ...], ...]`)`avgTimeToCompletion`string|nullAverage time from `cartCreated` to `orderCompleted` (e.g. `"2h"`, `"45m"`)`conversionRate`floatPercentage of carts that completed`cartsCreated`intUnique orders with a `cartCreated` event`ordersCompleted`intUnique orders with an `orderCompleted` event`avgCheckoutDuration`string|nullAverage time from `checkoutStarted` to `orderPaid``abandonmentRate`floatPercentage of carts that did not complete`abandonedCarts`intCount of abandoned carts`avgPaymentAttempts`floatAverage `paymentAttempt` events per completed order`returningCustomerRate`floatPercentage of completed orders from registered customers`avgCartValue`float|nullMean total price of completed orders`emailSuccessRate`floatEmail success rate as a percentage`emailsSent`intTotal `emailSent` events`emailsFailed`intTotal `emailFailed` events`days`intThe `$days` parameter echoed back

**Example:**

```php
$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](https://johnhenry.ie/plugins/order-lifecycle/docs/guide/ai-insights) for user-facing behaviour.

Access via:

```php
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](https://johnhenry.ie/plugins/order-lifecycle/docs/guide/ai-insights).

## ExportService

The `ExportService` builds CSV exports from log rows, batching order lookups to avoid N+1 queries.

Access via:

```php
use johnhenry\orderlifecycle\OrderLifecycle;

$export = OrderLifecycle::getInstance()->getExport();
```

### `<strong>generateCsv()</strong>`

**Signature:**

```php
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

- [Events API](https://johnhenry.ie/plugins/order-lifecycle/docs/api/events) - All EventType cases and what triggers them
- [Timeline View Guide](https://johnhenry.ie/plugins/order-lifecycle/docs/guide/timeline-view) - Viewing logs in the control panel
- [Statistics Guide](https://johnhenry.ie/plugins/order-lifecycle/docs/guide/statistics) - Analysing logged data
- [Export Guide](https://johnhenry.ie/plugins/order-lifecycle/docs/guide/export) - Exporting events to CSV
- [AI Insights Guide](https://johnhenry.ie/plugins/order-lifecycle/docs/guide/ai-insights) - AI-powered order and store analysis

On this page
