Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 03 / 06 35 MIN

The WooCommerce architecture

The Request Path From Cart to Order

A discount that the customer saw at checkout never reaches the order record. The change ran at the wrong stage, and the same fix has three different consequences.

A store adds a loyalty discount. Customers see it in the cart, the cart total drops, checkout completes, and the discount is missing from the order, from the invoice email and from the report the store uses to pay out points. Nothing threw. The change ran on a hook that fires before the totals are copied to the order, and the order copied the totals it was given.

Three hooks could have made that change, four if you count the ones inside the gateway, and the consequences differ at each one. What the customer sees, what the order records, and what the emails say are three separate questions, and the stage you choose decides which of them is true. So the lesson starts with the sequence, because the sequence is the thing being chosen between.

Cart changes are visible, temporary, and recalculated constantly

The cart is an object held in the session: WC()->cart. It holds line items, totals and applied coupons, and every page load can recalculate it. When a customer adds an item, the request is a normal POST that calls WC()->cart->add_to_cart(), which fires woocommerce_add_to_cart with the product id, quantity and item data.

The hook that matters most for pricing is woocommerce_before_calculate_totals. It fires on every cart recalculation, with the WC_Cart object as its argument, and it is how a plugin changes a line item's price or a total. It fires far more often than checkout does, and that frequency is the feature: the price the customer sees and the price stored on the order come from the same calculation, run at different moments.

<?php
add_action( 'woocommerce_before_calculate_totals', function ( $cart ) {
    if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
        return; // Never touch the cart in the admin, or you will fight the order editor.
    }

    foreach ( $cart->get_cart() as $item ) {
        if ( (int) $item['product_id'] === 88 ) {
            $item['data']->set_price( 19.00 );
        }
    }
}, 20 );

A fee is a different shape of change and goes on woocommerce_cart_calculate_fees, which fires inside the totals calculation after the line items are priced. Fees carry a name, an amount and an optional tax status, and they are what you use when the discount is not attached to one product: a handling charge, a payment surcharge, a loyalty credit.

Session state is the third piece. WC()->session holds the cart for logged-in and guest customers alike, along with WooCommerce's own keys such as order_awaiting_payment. Your keys go in through WC()->session->set() and come back with ->get(). Two rules matter: session writes survive across requests but not across cache layers that ignore the session cookie, and a session value stops existing when the session does. Anything you need to prove later belongs on the order.

Blocks checkout runs the same cart, later

The pricing hooks work in both the shortcode checkout and the Blocks checkout. Blocks does its early work over the Store API in separate requests, so a cart change that reads $_POST from the main request finds nothing there. Depend on the cart object and the session, and the change works on both.

Checkout submission produces data before it produces an order

When the customer submits the checkout form, WooCommerce runs two validation passes and then builds an order. The data at this point is an array of posted fields, not an order object, and it is still the customer's word rather than a record.

StageWhat existsWhat you have access to
woocommerce_after_checkout_validation Posted data, a WP_Error, and the cart Reject or annotate the submission before an order exists
woocommerce_checkout_create_order A WC_Order in memory, item and fee rows copied from the cart, not yet saved Read and write the whole order, including meta, address fields and line items
woocommerce_checkout_update_order_meta A saved order, plus the posted data array Add meta with a save of its own, which is the point of the hook
woocommerce_checkout_order_created A saved order with items, totals and addresses stored Notify a third party, register the order in an external system
woocommerce_checkout_order_processed A saved, unpaid order, at the end of the checkout request The last point before the gateway is asked for money

The distinction between the middle rows is the one people get wrong, so here it is in code. The create hook hands you the order before save() runs, which is why it is the correct place to add meta that should be written in the same insert as the order itself.

<?php
add_action( 'woocommerce_checkout_create_order', function ( $order, $data ) {
    $quote = WC()->session ? WC()->session->get( 'sifero_points_quote' ) : '';

    if ( $quote ) {
        $order->update_meta_data( '_sifero_points_quote', (int) $quote );
    }
}, 10, 2 );

That update_meta_data() needs no save() of its own, because checkout saves the order immediately afterwards. Use the same call on woocommerce_checkout_order_created and you do need one, since the order is already in the database. That single difference is why the two hooks are not interchangeable, and why the earlier one is usually the better home for order data.

Validation is the other half. woocommerce_after_checkout_validation gives you the posted data and a WP_Error object, and adding an error to it stops the order from being created at all. That is where a hard "no" belongs: a VAT number that must verify against a service, a customer outside your shipping zone. Rejecting later means you have created an order you then have to cancel, with a customer watching.

An order id is assigned once, and everything after that is a record

woocommerce_checkout_order_processed fires after the order exists and the id is stable, and it hands you the id, the posted data and the order. The status at this point is pending payment for a gateway that redirects, because no money has moved. An offline gateway such as cheque or bank transfer may already have moved the order to on-hold by then, so read the status rather than assuming it.

This is the last point at which a change is part of the checkout rather than a reaction to it. Your fraud score and your internal order number belong here or earlier, because the next step is the customer's browser leaving your site.

Payment completion is what moves an order from unpaid to paid

For a gateway that redirects, the customer leaves after this hook and the payment result arrives in a separate request. That request calls $order->payment_complete( $transaction_id ), which sets the date paid, optionally records the transaction id, sets the status to processing or completed depending on whether the order needs fulfilment, and saves. A gateway that settles immediately calls the same method inside the original request.

payment_complete() is the hinge, because it saves, and saving is what runs the status transition machinery. That machinery fires the hooks in a defined order: woocommerce_pre_payment_complete before the status is set, woocommerce_order_status_changed with the old and new status, the transition-specific woocommerce_order_status_pending_to_processing for the pair it just moved between, the destination hook such as woocommerce_order_status_processing, and finally woocommerce_payment_complete.

Which hook to use depends on what you need to know. For "money arrived", use woocommerce_payment_complete. For "the order is now processing, whatever the reason", use woocommerce_order_status_changed and compare the two statuses it gives you. The second fires in more situations than you expect, including a store manager moving an order through the admin list, and code that assumes payment happened will be wrong there.

<?php
add_action( 'woocommerce_order_status_changed', function ( $order_id, $from, $to, $order ) {
    if ( 'processing' !== $to || $from === $to ) {
        return;
    }

    // Both statuses arrive without the wc- prefix.
    $order->add_order_note( sprintf( 'Sifero saw %s to %s.', $from, $to ) );
    $order->save();
}, 10, 4 );

Emails render the order as it is saved, not as you meant it

Transactional email is a consequence of the sequence rather than a step in it, and the consequence is wired to specific transitions. The processing email is triggered by woocommerce_order_status_pending_to_processing, and the completed email by woocommerce_order_status_completed. A status change no mailer is attached to sends nothing; a change two mailers are attached to sends two messages.

Two consequences follow. First, anything you want in the email has to be saved before the transition, because the mailer reads the order from the data store while the transition is processed. Adding a meta row afterwards leaves the record and the customer's inbox disagreeing, and the inbox is the copy the customer quotes back at you. Second, sending mail happens inside the request that made the change, so a blocking call to a remote service here delays the customer's confirmation page. Save first, then transition.

To send your own mail rather than modify WooCommerce's, build a class that extends WC_Email and register it on the woocommerce_email_classes filter. That route gets you the template system, the per-email switch in settings and the send bookkeeping the core emails have. Calling wp_mail() from a status hook works, and appears in no settings screen the store owner can reach.

The same change at three points, with three outcomes

Take a loyalty credit of five euros and follow it to the three places it could be applied. This is the whole argument of the lesson in one table.

StageWhat the customer seesWhat the store records
Cart, on woocommerce_cart_calculate_fees A fee line reading minus five euros, and a reduced total An order fee row, if the fee is still on the cart when checkout copies it
Checkout, on woocommerce_checkout_create_order The total from the cart, unchanged Whatever you write to the order, saved with the order in the same insert
Payment, on woocommerce_payment_complete The email already sent, with the original total A corrected order that no longer matches the customer's copy

The first row is the only one that changes what the customer is charged, so it is the only one that is a price. The second changes the order record without changing the charge, which is right for a credit applied after tax is calculated and wrong if you meant to reduce what the customer pays. The third is a correction, and corrections have a cost: the email is out, the payment is captured, and the store now holds two versions of the same event.

There is no universal answer, and I would be suspicious of anyone who offers one. Decide which question you are answering before you write the hook: does this change what the customer pays, what the store records, or what the customer is told. Most production bugs here are one of those three asked at the wrong stage.

Lab 35 minutes

On your test store, give every order a "source" value of sifero-web when a customer checks out from the storefront, and prove where it landed.

Do it three times, on three separate branches of code, and run a real checkout each time:

  1. Write it as a cart fee or cart-level session value.
  2. Write it on woocommerce_checkout_create_order as order meta.
  3. Write it on woocommerce_checkout_order_processed as order meta, with a save().

After each checkout, run this and record what you get:

wp eval '$o = wc_get_order( ORDER_ID ); var_dump( $o->get_meta( "_sifero_source" ) ); var_dump( $o->get_total() );'

Then answer: which of the three versions survived a page reload, which one changed the amount the customer was charged, and which one would still be correct if you moved the store to a gateway that redirects the customer off-site and returns a minute later. Write your answers down before you look at the solution.

Show the solution and the reasoning

Only the second version is right for this requirement, and the reasoning is about when the order is written rather than about which hook has the nicer name.

<?php
add_action( 'woocommerce_checkout_create_order', function ( $order, $data ) {
    $order->update_meta_data( '_sifero_source', 'sifero-web' );
}, 10, 2 );

Walking the three attempts:

The cart version does not record anything on the order. A session value is gone when the session ends, and a cart fee becomes an order fee row rather than order meta. You can make the session value into order meta, but only by copying it during checkout, which is the second version with an extra step. The var_dump returns an empty string for the meta key and a total that includes your fee if you wrote one, which tells you the change was about price, not about provenance.

The create_order version survives because checkout saves the order immediately after the hook returns, and update_meta_data() has already marked the row dirty. The second request reads it from wc_orders_meta or wp_postmeta depending on the store, and it comes back unchanged. The total is untouched, which is correct: the source of an order is not a price.

The processed version also survives, because it includes an explicit save(), and the output looks identical to the second version. Trying both is how people conclude the hooks are interchangeable. They are not, and the difference shows in the failure case: with the meta written on woocommerce_checkout_create_order, an order abandoned before payment still carries its source. Write it on a payment hook and abandoned orders have no source at all, which is the exact set of orders a support person asks about.

That is the practical rule for the module. Data that should exist for every order belongs on woocommerce_checkout_create_order, because it is written in the same insert as the order and cannot be lost by a later step failing. Data that only becomes true after money moves belongs on a payment or status hook. Getting this backwards leaves order records complete for successful purchases and empty for every interesting failure.

Progress

Mark this lesson done and it stays marked in this browser.