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, orcalculate_totals.
Status APIs
Use unprefixed statuses in order object APIs:
$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:
$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:
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_completewhen the gateway follows the canonical API, orwoocommerce_order_payment_status_changedfor 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:v1before 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.
$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 |
$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
$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 onwoocommerce_payment_complete,woocommerce_order_status_completed,woocommerce_order_status_processing, andwoocommerce_order_status_on-hold.wc_maybe_increase_stock_levels()runs onwoocommerce_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_stockto avoid reducing stock twice. woocommerce_order_item_quantityfilters the quantity used for stock reduction.woocommerce_reduce_order_item_stock,woocommerce_reduce_order_stock, andwoocommerce_restore_order_stocklet 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:
$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 forpayment_complete(). - Passing
wc-processingto 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_Iteminstead ofWC_Order_Item_Product,WC_Order_Item_Fee,WC_Order_Item_Shipping,WC_Order_Item_Coupon, orWC_Order_Item_Tax. - Editing items and forgetting
calculate_totals()andsave(). - Expecting
woocommerce_removed_order_itemsto fire synchronously fromremove_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.phpwp-content/plugins/woocommerce/includes/abstracts/abstract-wc-order.phpwp-content/plugins/woocommerce/includes/class-wc-order-item.phpwp-content/plugins/woocommerce/includes/class-wc-order-item-product.phpwp-content/plugins/woocommerce/includes/wc-order-functions.phpwp-content/plugins/woocommerce/includes/wc-stock-functions.phpwp-content/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php