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
| Concern | Classic checkout | Checkout 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.
Add a VAT number to both checkouts in one plugin, and prove which path ran.
- Register
billing_vatonwoocommerce_checkout_fieldsat priority 25, save it onwoocommerce_checkout_create_orderunder_sifero_vat, and add a rule onwoocommerce_after_checkout_validationthat 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 theorderlocation, 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_detailsand 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.