# Wc Order Lifecycle And Items

> Work safely with WooCommerce order statuses, payment completion, status hooks, order items, line-item meta, totals, stock side effects, and paid analytics/conversion idempotency. Covers `payment_complete()` vs `update_status()`, status-hook ordering, `woocommerce_thankyou` vs paid events, replay-safe external side effects, concrete order-item classes, totals, HPOS-safe CRUD, and stock handling. Use when reacting to orders, changing statuses/items, provisioning, fulfillment, stock logic, external conversion events, or debugging paid orders that skipped lifecycle side effects.

- Skill: `lonsdale201/wc-order-lifecycle-and-items` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lonsdale201/wc-order-lifecycle-and-items`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lonsdale201/wc-order-lifecycle-and-items/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: Lonsdale201 (https://skillmd.com/u/lonsdale201)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lonsdale201/wc-order-lifecycle-and-items

---


# WooCommerce order lifecycle and items

Use this when plugin code reacts to orders, changes statuses, creates or edits order items, changes totals, or depends on stock/payment side effects.

## Misconception this skill corrects

> "The payment succeeded, so I can just call `$order->update_status( 'completed' )`."

For a real payment success path, gateways should call `$order->payment_complete( $transaction_id )`. That clears the awaiting-payment session flag, sets transaction/date-paid data, chooses processing vs completed via `woocommerce_payment_complete_order_status`, saves, and fires `woocommerce_payment_complete`. `update_status()` still fires status hooks, emails, and stock handlers, but it does not perform the full paid-order contract.

## When to use this skill

Trigger when ANY of the following is true:

- A plugin changes order status.
- A plugin reacts to `processing`, `completed`, `cancelled`, `failed`, `refunded`, or custom statuses.
- A gateway, webhook, fulfillment integration, ERP sync, license grant, stock adjustment, or provisioning flow touches orders.
- Code adds, removes, or edits order items.
- Code modifies item meta or order totals.
- The diff contains `payment_complete`, `update_status`, `set_status`, `woocommerce_order_status_`, `woocommerce_order_status_changed`, `woocommerce_order_payment_status_changed`, `WC_Order_Item_Product`, `add_item`, or `calculate_totals`.

## Status APIs

Use unprefixed statuses in order object APIs:

```php
$order = wc_get_order( $order_id );

if ( $order instanceof WC_Order && $order->has_status( 'processing' ) ) {
    $order->update_status(
        'completed',
        __( 'Marked complete by MyPlugin.', 'myplugin' ),
        true
    );
}
```

`WC_Order::set_status()` stages the transition on the object. `WC_Order::update_status()` calls `set_status()` and saves immediately. The method docblock confirms no internal `wc-` prefix is required.

Gateway or webhook payment success:

```php
$order = wc_get_order( $order_id );

if ( $order instanceof WC_Order && $provider_status === 'captured' ) {
    $order->payment_complete( $transaction_id );
}
```

Use `update_status( 'on-hold' )`, `update_status( 'failed' )`, or `update_status( 'cancelled' )` for non-success states.

Custom stored order-status keys have a 20-character schema limit, and WooCommerce's save guard measures the full stored key in bytes including the `wc-` prefix. WooCommerce 11.0 emits a doing-it-wrong notice when a CPT-backed order would save a longer status, but the schema limit still makes that value unsafe across HPOS/legacy modes. Keep a custom unprefixed slug at 17 ASCII characters or fewer, register `wc-<slug>`, and test persistence—a UI label may be arbitrarily longer.

## Status hook ordering

When a saved order status transition is processed, WooCommerce fires hooks in this order:

| Hook | Args | Notes |
|---|---|---|
| `woocommerce_order_status_{$to}` | `$order_id, $order, $status_transition` | Fires first. |
| status transition note | internal | Skipped for draft/new/checkout-draft origins. |
| `woocommerce_order_status_{$from}_to_{$to}` | `$order_id, $order` | Only when there is a previous status. |
| `woocommerce_order_status_changed` | `$order_id, $from, $to, $order` | General transition hook. |
| `woocommerce_order_payment_status_changed` | `$order_id, $order` | Only pending/failed to paid status. |

Use narrow hooks for narrow logic, and `woocommerce_order_status_changed` for transition-aware logic:

```php
add_action(
    'woocommerce_order_status_changed',
    static function ( int $order_id, string $from, string $to, WC_Order $order ): void {
        if ( 'processing' !== $to || 'processing' === $from ) {
            return;
        }

        as_enqueue_async_action(
            'myplugin_sync_paid_order',
            array( 'order_id' => $order_id ),
            'myplugin'
        );
    },
    10,
    4
);
```

Do not perform slow API calls directly inside status hooks. Enqueue a job and make the job idempotent.

## Model paid business events separately from page and status events

`woocommerce_thankyou` proves that a receipt/order-received page rendered. It
does not prove capture or payment: the order can be pending, on-hold, failed
later, or the page can be refreshed. Likewise, `woocommerce_new_order` is not a
stable Purchase event and can run before every downstream integration has the
final item snapshot it expects.

For a paid conversion, start from WooCommerce's payment lifecycle:

- prefer `woocommerce_payment_complete` when the gateway follows the canonical
  API, or `woocommerce_order_payment_status_changed` for the pending/failed to
  paid transition;
- load the order fresh and verify `is_paid()`/date paid plus the product's
  accepted status policy;
- claim a durable logical event such as `order:{id}:purchase:v1` before enqueueing
  the remote job; status hooks can replay after manual transitions and retries;
- pass the same operation key to a provider idempotency field/header and retain
  reconciliation state when the provider cannot enforce it.

A unique insert or `INSERT IGNORE` is only useful if downstream actions are
emitted **only when this invocation created the event**. Calling `do_action()`
or a remote API unconditionally after a duplicate/no-op insert bypasses local
deduplication. Keep local claim, queue creation, remote delivery, and completion
states explicit.

Model refund, cancellation, and Subscriptions renewal payments as separate
versioned business events. Do not infer them by replaying the initial Purchase.
Test pending/on-hold, processing, completed, failed, thank-you refresh, manual
status reversal, webhook retry, partial/full refund, and renewal orders.

## Order creation hooks

`woocommerce_new_order` is not a universal "checkout just started" hook. Since WC 10.8 the CPT and HPOS stores skip normal new-order behavior for draft/new/checkout-draft transitions and fire it when the order becomes non-draft. For checkout-specific behavior, use checkout hooks such as `woocommerce_checkout_order_created` or `woocommerce_checkout_order_processed`.

## Add a product line item

Use concrete item classes. Do not instantiate base `WC_Order_Item`; WC 9.9+ warns against direct base-item instantiation.

```php
$order   = wc_get_order( $order_id );
$product = wc_get_product( $product_id );

if ( $order instanceof WC_Order && $product instanceof WC_Product ) {
    $price = (float) $product->get_price( 'edit' );

    $item = new WC_Order_Item_Product();
    $item->set_product( $product );
    $item->set_quantity( 1 );
    $item->set_subtotal( $price );
    $item->set_total( $price );
    $item->add_meta_data( '_myplugin_source', 'manual-adjustment', true );

    $order->add_item( $item );
    $order->calculate_totals();
    $order->save();
}
```

`add_item()` attaches the item to the order object and assigns a temporary item key until save. Recalculate totals after changing items, fees, shipping, discounts, or taxes.

## Remove all items: WooCommerce 11.0 save boundary

`$order->remove_order_items( $type )` now clears matching items from the in-memory order immediately but defers the database deletion until the next `$order->save()`. This makes checkout's resume-order rebuild atomic: if rebuilding throws before save, the persisted items remain.

Hook timing is therefore split:

| Hook | Timing in WooCommerce 11.0 |
|---|---|
| `woocommerce_remove_order_items` | Synchronous, before the in-memory clear is queued |
| `woocommerce_removed_order_items` | During `save_items()`, after the database delete succeeds |

```php
$order->remove_order_items( 'line_item' );

// The object now reports no product lines, but persisted rows are not deleted yet.
$order->save();

// The post-hook has now fired and persisted rows are gone.
```

Do not pair the pre/post hooks as if they bracket one synchronous call. Save before querying persisted state from another process, and expect the post-hook to fire from the later save stack. Pass a valid item-type string or `null` for all item types; WooCommerce 11.0 rejects other PHP types with a doing-it-wrong notice and leaves state unchanged.

## Edit existing line items

```php
$order = wc_get_order( $order_id );

if ( $order instanceof WC_Order ) {
    foreach ( $order->get_items( 'line_item' ) as $item ) {
        if ( ! $item instanceof WC_Order_Item_Product ) {
            continue;
        }

        $item->add_meta_data( '_myplugin_exported', current_time( 'mysql', true ), true );
        $item->save();
    }

}
```

`$item->save()` persists an item-only change; an additional `$order->save()` is needed only when order properties also changed. For machine data, use private meta keys. For customer/admin-visible item meta, use stable keys and translate only the display label.

In WooCommerce 10.9+, `$item->get_order()` returns the item's already-associated order instance when available. It is not guaranteed to be an independent snapshot: mutating that object mutates the same in-memory order used by surrounding code.

## Stock side effects

WooCommerce already wires stock reduction and restoration to order lifecycle hooks:

- `wc_maybe_reduce_stock_levels()` runs on `woocommerce_payment_complete`, `woocommerce_order_status_completed`, `woocommerce_order_status_processing`, and `woocommerce_order_status_on-hold`.
- `wc_maybe_increase_stock_levels()` runs on `woocommerce_order_status_cancelled`, `woocommerce_order_status_pending`, and—new in WooCommerce 11.0—`woocommerce_order_status_failed`. The failed transition now restores inventory previously reduced while an asynchronous payment order was on hold.
- Each line item stores `_reduced_stock` to avoid reducing stock twice.
- `woocommerce_order_item_quantity` filters the quantity used for stock reduction.
- `woocommerce_reduce_order_item_stock`, `woocommerce_reduce_order_stock`, and `woocommerce_restore_order_stock` let integrations observe changes.

Do not call `wc_reduce_stock_levels()` blindly in payment/webhook code. In the normal paid flow, `payment_complete()` and the status hooks already cover it. If you implement custom stock behavior, respect `_reduced_stock` and make the operation idempotent.

Checkout stock reservation is separate from paid-order stock reduction. Prefer `wc_reserve_stock_for_order( $order )`; it passes the store's `woocommerce_hold_stock_minutes` setting. If version-pinned code directly calls internal `ReserveStock::reserve_stock_for_order()`, WooCommerce 11.0 defaults an omitted duration to 60 minutes. Pass the intended minutes explicitly instead of silently inheriting that fallback.

If an extension deliberately replaces WooCommerce's default `intval` callback on `woocommerce_stock_amount` with `floatval` for fractional inventory, WooCommerce 11.0 preserves positive quantities below `1` instead of casting them to zero during product validation. Merely adding `floatval` after the still-active `intval` callback is too late—the fraction is already lost. Keep quantity handling numeric and consistent across cart, order-item, stock, REST, and reporting code; never mix a fractional stock policy with integer-only downstream assumptions.

## HPOS-safe order data

Orders are not posts in HPOS mode. Use WooCommerce CRUD:

```php
$order = wc_get_order( $order_id );

if ( $order instanceof WC_Order ) {
    $order->update_meta_data( '_myplugin_external_id', $external_id );
    $order->save();
}
```

Do not use `get_post_meta()`, `update_post_meta()`, `WP_Query` over `shop_order`, or direct `wp_postmeta` SQL for order state.

## Common mistakes

- Using `update_status( 'completed' )` as a payment success replacement for `payment_complete()`.
- Passing `wc-processing` to object methods that expect unprefixed statuses.
- Running slow fulfillment/API calls directly inside order status hooks.
- Treating `woocommerce_thankyou`, checkout completion, or order creation as
  proof of successful payment/Purchase.
- Deduplicating a local row but firing the conversion action even when the
  insert was a duplicate/no-op.
- Instantiating `WC_Order_Item` instead of `WC_Order_Item_Product`, `WC_Order_Item_Fee`, `WC_Order_Item_Shipping`, `WC_Order_Item_Coupon`, or `WC_Order_Item_Tax`.
- Editing items and forgetting `calculate_totals()` and `save()`.
- Expecting `woocommerce_removed_order_items` to fire synchronously from `remove_order_items()` in WooCommerce 11.0.
- Calling stock reduction manually after WooCommerce already did it.
- Saving order data through post meta instead of CRUD APIs.

## Cross-skill routing

- Payment gateway process and webhook success: `wc-payment-gateway`
- HPOS storage/query compatibility: `wc-hpos-compatibility`
- Background work from order hooks: `wc-action-scheduler-jobs`
- Cart/checkout line-item meta before order creation: `wc-cart-checkout-classic`

## References

- Official documentation: <https://woocommerce.github.io/code-reference/classes/WC-Order.html>
- Verified source paths:
  - `wp-content/plugins/woocommerce/includes/class-wc-order.php`
  - `wp-content/plugins/woocommerce/includes/abstracts/abstract-wc-order.php`
  - `wp-content/plugins/woocommerce/includes/class-wc-order-item.php`
  - `wp-content/plugins/woocommerce/includes/class-wc-order-item-product.php`
  - `wp-content/plugins/woocommerce/includes/wc-order-functions.php`
  - `wp-content/plugins/woocommerce/includes/wc-stock-functions.php`
  - `wp-content/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php`

