Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 09 / 18 40 MIN

Cart and checkout

Checkout Fields and the Blocks Checkout

The classic checkout field hook, the four arrays it edits, and why a field added that way vanishes the moment the store switches to the Checkout block.

A store adds a VAT number to the checkout with woocommerce_checkout_fields, saves it on the order, and prints it on the invoice. It works. Then the store rebuilds its checkout page with the Checkout block, and the field is gone. No error, no deprecation notice, no console warning. The plugin is still active and the hook still runs; the page it was written for is no longer the page being rendered.

Classic checkout fields and blocks checkout fields are two systems that happen to collect the same information. One is four PHP arrays filtered on a request and rendered by a template. The other is a set of React components fed by the Store API, registered through a function with stricter argument validation than anything in the classic path. Writing for both means writing twice, and the point of this lesson is to know exactly where the seam is so you can stop guessing which of your two implementations is running.

Target is WooCommerce 11.2.1 on WordPress 7.0, PHP 8.3 or newer. Hooks, argument lists, section keys, validation rules and meta key prefixes were read from includes/class-wc-checkout.php, src/Blocks/Domain/Services/CheckoutFields.php, src/Blocks/Domain/Services/functions.php, src/StoreApi/ and src/Blocks/Integrations/IntegrationInterface.php. The block tree and the JavaScript export list came from reading the shipped block.json files and the blocks-checkout build output in that tree; no booted WordPress install was available here.

Classic fields are four arrays assembled per request and filtered once

WC_Checkout::initialize_checkout_fields() builds the whole structure from the customer's country, then hands it to one filter:

$this->fields = array(
    'billing'  => WC()->countries->get_address_fields( $billing_country, 'billing_' ),
    'shipping' => WC()->countries->get_address_fields( $shipping_country, 'shipping_' ),
    'account'  => array(),
    'order'    => array(),
);

Those four keys are the whole surface. billing and shipping come back from WC_Countries::get_address_fields() with the prefix already applied. Your key is billing_vat. vat goes nowhere. Core adds order_comments to the order array when woocommerce_enable_order_notes_field passes, and account fields to account when the generate-username or generate-password options are off. Everything else is yours, and the filter is one line:

$this->fields = apply_filters( 'woocommerce_checkout_fields', $this->fields );

Two things happen after the filter. Each section is sorted with wc_checkout_fields_uasort_comparison, so your field's position comes from its priority, not the order you added it. And a field with a placeholder and no label gets the placeholder promoted to its label.

get_checkout_fields( $fieldset = '' ) is the only reader, and it caches into $this->fields on the first call, which makes the filter a once-per-request event: a callback registered after something has already asked for the fields never runs. Attach on plugins_loaded or init, not from inside a template.

Saving is a separate hook. process_checkout() fires woocommerce_checkout_create_order with the order object and the posted data, then the older woocommerce_checkout_update_order_meta with the order id and the posted data. Both exist in 11.2.1. Use the first and write through the CRUD layer, which is the pair of arguments that works on HPOS:

<?php
add_filter( 'woocommerce_checkout_fields', function ( $fields ) {
    $fields['billing']['billing_vat'] = array(
        'type'        => 'text',
        'label'       => __( 'VAT number', 'sifero-vat' ),
        'required'    => false,
        'class'       => array( 'form-row-wide' ),
        'priority'    => 25,
        'autocomplete' => 'off',
    );

    return $fields;
} );

add_action( 'woocommerce_checkout_create_order', function ( $order, $data ) {
    $vat = isset( $_POST['billing_vat'] ) ? wc_clean( wp_unslash( $_POST['billing_vat'] ) ) : '';

    if ( '' !== $vat ) {
        $order->update_meta_data( '_sifero_vat', $vat );
    }
}, 10, 2 );

Validation walks those same arrays and ends with a WP_Error you can add to

WC_Checkout::validate_posted_data( &$data, &$errors ) iterates get_checkout_fields() and applies three things from each field definition. It skips a whole fieldset through maybe_skip_fieldset(), which drops shipping when ship_to_different_address is empty and drops account when registration is not required. It treats required as required unless hidden is also true. And it runs the validate array through WooCommerce's own checks for postcode, phone, email and state. Failures go in as $errors->add( $key . '_validation', $message ).

All three are properties of one field, so none can express a rule about two fields together. That is what the last hook in the method is for:

do_action( 'woocommerce_after_checkout_validation', $data, $errors );

The second argument is a WP_Error, and adding to it is how you stop an order from being created. validate_checkout() calls this after field validation, the cart items check, terms, and the shipping and payment checks, so your callback sees the most complete picture the classic checkout offers. Add errors with a code nobody else uses, because the code is the key in the error array and a collision silently overwrites another plugin's message. The hook fires only from process_checkout(), and in 11.2.1 the only two call sites of that method are WC_Form_Handler and WC_AJAX::checkout().

The Checkout block is 24 inner blocks, and none of them reads your classic fields

The block's own block.json declares woocommerce/checkout with no inner blocks at all. The structure comes from the parent key on the blocks inside it, and walking those files in the 11.2.1 tree gives:

block.json files under assets/client/blocks: 174
names starting woocommerce/checkout: 24

Inner-block tree rooted at woocommerce/checkout
woocommerce/checkout                                 Checkout
  woocommerce/checkout-fields-block                    Checkout Fields
    woocommerce/checkout-shipping-address-block          Shipping Address
    woocommerce/checkout-pickup-options-block            Pickup Method
    woocommerce/checkout-contact-information-block       Contact Information
    woocommerce/checkout-actions-block                   Actions
    woocommerce/checkout-payment-block                   Payment Options
    woocommerce/checkout-shipping-methods-block          Shipping Options
    woocommerce/checkout-terms-block                     Terms and Conditions
    woocommerce/checkout-billing-address-block           Billing Address
    woocommerce/checkout-express-payment-block           Express Checkout
    woocommerce/checkout-additional-information-block    Additional information
    woocommerce/checkout-order-note-block                Order Note
    woocommerce/checkout-shipping-method-block           Delivery
  woocommerce/checkout-totals-block                    Checkout Totals
    woocommerce/checkout-order-summary-block             Order Summary
      woocommerce/checkout-order-summary-totals-block      Totals
        woocommerce/checkout-order-summary-fee-block         Fees
        woocommerce/checkout-order-summary-shipping-block    Shipping
        woocommerce/checkout-order-summary-discount-block    Discount
        woocommerce/checkout-order-summary-taxes-block       Taxes
        woocommerce/checkout-order-summary-subtotal-block    Subtotal
      woocommerce/checkout-order-summary-coupon-form-block Coupon Form
      woocommerce/checkout-order-summary-cart-items-block  Cart Items

No block in that list renders a billing VAT field, and none consults WC()->checkout()->get_checkout_fields() to decide what to render. The address blocks read field definitions from the Store API response and from the defaultFields entry CheckoutFields::add_fields_data() puts in the asset data registry. The PHP side is a container: Checkout::render() returns markup with a data-block-name attribute per inner block, includes a fallback for first-iteration templates, and defers to the [woocommerce_checkout] shortcode when the checkout is an endpoint page.

Blocks fields are registered through one function that validates its options hard

The registration function is woocommerce_register_additional_checkout_field( $options ), defined in src/Blocks/Domain/Services/functions.php inside a function_exists guard. It takes one associative array. If woocommerce_blocks_loaded has not fired yet it re-registers itself on that action and returns, so calling it early is safe. Otherwise it delegates to CheckoutFields::register_checkout_field() and throws an Exception if registration returned a WP_Error.

<?php
add_action( 'woocommerce_init', function () {
    woocommerce_register_additional_checkout_field(
        array(
            'id'       => 'sifero/vat-number',
            'label'    => __( 'VAT number', 'sifero-vat' ),
            'location' => 'address',
            'type'     => 'text',
            'required' => false,
        )
    );
} );

The options are not free-form. The defaults and the checks in register_checkout_field() and validate_options() give the actual contract. id is required and must contain a /, because it has to be namespaced. location is required and must be address, contact or order; the value additional still works but is deprecated since 8.9.0 and rewritten to order. type defaults to text, and if you supply one it must be text, select, checkbox or date. Registering the same id twice is a _doing_it_wrong, and so is registering before after_setup_theme, a warning added in 11.0.0 saying fields belong on woocommerce_init or later. The remaining defaults are optionalLabel built from your label, hidden false, an empty attributes array, show_in_order_confirmation true, and sanitize_callback and validate_callback pointing at the field type class.

location decides where the value goes. CheckoutFieldsStorage::set_array_meta() writes update_meta_data( get_group_key( $group ) . $key, $value ), so a field in address is stored twice, under _wc_billing/<id> and _wc_shipping/<id>, while contact and order fields go to _wc_other/<id>. The same method fires woocommerce_set_additional_field_value with the key, value, group and the order or customer object, which is the server-side save hook for a blocks field. The keys also pass through woocommerce_customer_allowed_session_meta_keys, so a blocks field survives a page reload. The _wc_additional/ prefix in older tutorials is deprecated since 8.9.0 in favour of _wc_other/.

Extensions reach the blocks checkout through the Store API and slot fills

On the server, the blocks checkout runs through Store API routes, and those routes fire their own actions rather than the classic ones. The ones you will use, read from src/StoreApi/: woocommerce_store_api_checkout_update_order_from_request, woocommerce_store_api_checkout_order_created, woocommerce_store_api_checkout_order_processed, woocommerce_store_api_checkout_update_order_meta, woocommerce_store_api_cart_update_order_from_request, woocommerce_store_api_cart_update_customer_from_request, woocommerce_store_api_cart_errors, woocommerce_store_api_validate_add_to_cart and woocommerce_store_api_validate_cart_item. Core's comments explain the duplication: the one above woocommerce_store_api_checkout_order_processed says it is similar to woocommerce_checkout_order_processed but passes only the order, and that the new name "explicitly indicates these orders are from checkout block/StoreAPI".

On the client, the checkout UI extends through the @woocommerce/blocks-checkout script. Its export list in the 11.2.1 build gives two families. For values, registerCheckoutFilters and applyCheckoutFilter, plus the older __experimentalRegisterCheckoutFilters and __experimentalApplyCheckoutFilter that the build marks as graduated. For markup, the slot-fill components ExperimentalOrderMeta, ExperimentalDiscountsMeta, ExperimentalOrderShippingPackages and ExperimentalOrderLocalPickupPackages, built with createSlotFill, useSlot and useSlotFills. Around those sit registerCheckoutBlock, getRegisteredBlocks and innerBlockAreas for adding a block at a known position, and extensionCartUpdate for pushing a change into the cart and getting recalculated totals back. The script's asset file lists the dependencies it expects on the page, including wc-cart-checkout-base and wc-cart-checkout-vendors.

The PHP half of that is Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface: get_name(), initialize(), get_script_handles(), get_editor_script_handles() and get_script_data(). The data method is how you get server values into your slot fill. It is the piece people forget, and their component renders an empty span.

The contrast, stated plainly

ConcernClassic checkoutCheckout block
Declare a field woocommerce_checkout_fields, keys inside billing, shipping, account or order woocommerce_register_additional_checkout_field(), id namespaced, location one of three values
Where it renders A PHP template reading WC()->checkout()->get_checkout_fields() React components fed by the Store API cart and checkout responses
Validate across fields woocommerce_after_checkout_validation with a WP_Error Field validation rules; server-side checks on the Store API route
Save to the order woocommerce_checkout_create_order Automatic into _wc_billing/, _wc_shipping/ or _wc_other/, or your own action
Order lifecycle woocommerce_checkout_order_processed woocommerce_store_api_checkout_order_processed
Add markup A template override or a hook in the form HTML A slot fill or a registered inner block

Two claims are commonly overstated. The first is that woocommerce_checkout_fields does nothing on a page using the Checkout block. Nothing in that request path calls get_checkout_fields(): the address blocks use Store API field data, and the one place core consults the classic arrays from the cart, in WC_Cart::needs_shipping(), is guarded by if ( 'shortcode' === $this->cart_context ) and uses the customer's stored address on a store-api cart instead. Nothing fires the filter at all.

The second is that WC()->checkout is not absent from a blocks checkout. The Store API calls methods on it directly, including create_order_line_items(), create_order_fee_lines() and the rest, to build the order from the cart. What it never calls is process_checkout(), which is where the classic field filter and the classic validation hook live. The object is present; the two entry points you attached to are not on the path.

The practical consequence is that one implementation does not cover both checkouts. A store with a shortcode checkout and a block checkout page, common during a migration, needs the field declared through both mechanisms and the value saved from both paths. Test on both pages, because the failure mode is silence.

Lab 45 minutes

Add a VAT number to both checkouts in one plugin, and prove which path ran.

  • Register billing_vat on woocommerce_checkout_fields at priority 25, save it on woocommerce_checkout_create_order under _sifero_vat, and add a rule on woocommerce_after_checkout_validation that rejects a VAT number shorter than eight characters only when the billing country is not the base country.
  • Register the same field for the blocks checkout with woocommerce_register_additional_checkout_field() in the order location, and log the order meta keys that appear on the created order.
  • Put the same order through a shortcode checkout and a block checkout and compare the meta keys on the two orders.
  • Move the classic filter registration to woocommerce_checkout_before_customer_details and explain what changes.

Then answer: on a block checkout, does woocommerce_after_checkout_validation fire at all, and if not, which action replaces it for a server-side rule that depends on the posted field?

Show the solution and the reasoning
<?php
// Classic checkout.
add_filter( 'woocommerce_checkout_fields', function ( $fields ) {
    $fields['billing']['billing_vat'] = array(
        'type'     => 'text',
        'label'    => __( 'VAT number', 'sifero-vat' ),
        'required' => false,
        'priority' => 25,
    );

    return $fields;
} );

add_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {
    $vat     = isset( $data['billing_vat'] ) ? trim( (string) $data['billing_vat'] ) : '';
    $country = $data['billing_country'] ?? '';

    if ( '' !== $vat && strlen( $vat ) < 8 && wc_get_base_location()['country'] !== $country ) {
        $errors->add( 'sifero_vat_length', __( 'That VAT number looks too short.', 'sifero-vat' ), array( 'id' => 'billing_vat' ) );
    }
}, 10, 2 );

add_action( 'woocommerce_checkout_create_order', function ( $order, $data ) {
    $vat = isset( $data['billing_vat'] ) ? wc_clean( $data['billing_vat'] ) : '';
    if ( '' !== $vat ) {
        $order->update_meta_data( '_sifero_vat', $vat );
    }
}, 10, 2 );

// Blocks checkout.
add_action( 'woocommerce_init', function () {
    woocommerce_register_additional_checkout_field(
        array(
            'id'       => 'sifero/vat-number',
            'label'    => __( 'VAT number', 'sifero-vat' ),
            'location' => 'order',
            'type'     => 'text',
            'required' => false,
        )
    );
} );

Why the two paths produce different meta keys. The classic path writes _sifero_vat, the key your callback chose. The blocks path writes _wc_other/sifero/vat-number, because the field id and its location determine the key and you do not get to pick it. Both orders contain the VAT number and neither contains the other key, so any reporting query reading one key misses half the orders. Write the classic callback to the same prefixed key, or read both.

The country rule. $data on woocommerce_after_checkout_validation is the posted data, so billing_country is there. Comparing against wc_get_base_location()['country'] rather than a hardcoded code makes the rule survive a store that changes its base. The third argument to $errors->add() is error data, and core uses array( 'id' => $key ) so the form can attach the message to the right input; a unique error code keeps another plugin's message from being overwritten.

What the block checkout does instead. woocommerce_after_checkout_validation does not fire, because it is called from WC_Checkout::process_checkout() and the Store API never calls that method. The replacement depends on what you are validating. For a rule about the field itself, the field's validation option is evaluated through validate_options() and the field type class. For a rule involving the rest of the request, use woocommerce_store_api_checkout_update_order_from_request, which receives the order and the WP_REST_Request, and throw a RouteException to stop checkout. For a rule about the cart, woocommerce_store_api_cart_errors receives the errors object and the cart. There is no single drop-in equivalent. The two checkouts need separate code.

Registering the filter late. Moving the classic filter to woocommerce_checkout_before_customer_details fails in a confusing way. get_checkout_fields() caches into $this->fields the first time anything asks, and rendering the form template is the first thing that asks. A hook firing during that render is too late: apply_filters( 'woocommerce_checkout_fields' ) already ran, the array is cached, and your field lands on a local copy nothing reads. The template renders without it and raises nothing. Attach on init or plugins_loaded.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

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