You needed one extra field at checkout — a VAT number, a delivery date, a “how did you hear about us?” dropdown. You searched, found a snippet built on the woocommerce_checkout_fields filter, dropped it into your child theme, and reloaded the page.
Nothing happened. No error, no warning, no PHP notice. The field simply isn’t there.
This is the single most common WooCommerce support question of the block-checkout era, and almost every tutorial still ranking for it gives an answer that stopped working in 2023. Here is what actually happened, what to use instead, and the part that even the correct tutorials get wrong.
Quick answer
The woocommerce_checkout_fields filter only ever touched the classic shortcode checkout. The Cart and Checkout blocks — the default for every store created since WooCommerce 8.3 — build their form from the Store API, not from a PHP template, so that filter never runs. Register fields with woocommerce_register_additional_checkout_field() instead (WooCommerce 8.9+). It supports text, select, checkbox and, from WooCommerce 11.2, date fields.
Why the old filter silently does nothing
The classic checkout was a PHP template. WooCommerce assembled an array of fields, ran it through woocommerce_checkout_fields, and echoed the result into form-checkout.php. Filtering that array was the whole extension model, and it worked because PHP was doing the rendering.
The Checkout block is not a PHP template. It is a React application that asks the Store API — WooCommerce’s REST-based commerce API — for a schema describing which fields exist, then renders them in the browser. The schema is assembled from a separate registry. Your filter still fires somewhere deep in WooCommerce’s classic code path; the block just never asks that code path anything.
That is why there is no error. From PHP’s point of view your callback ran perfectly. It simply modified an array nobody downstream reads.
It is also why the block checkout can be driven by things that are not browsers — a mobile app, a headless storefront, or the shopping agents we covered in what agentic commerce actually does for online stores. A machine-readable field schema is the point, not a side effect.
The part most tutorials get wrong: some old filters still matter
“The old hooks don’t work on block checkout” is repeated everywhere, and it is too blunt to be safe. The truth is split down the middle, and the half that people get wrong is the dangerous half.
Adding a brand-new field through woocommerce_checkout_fields does nothing on block checkout. But WooCommerce’s core address fields — first name, company, address line 2, postcode, state, phone — are still driven by the country locale system, and the block reads that. So woocommerce_default_address_fields and woocommerce_get_country_locale genuinely do change what the block renders and what it treats as required.
| What you want to do | Classic checkout | Block checkout |
|---|---|---|
| Add a new field | woocommerce_checkout_fields |
Use woocommerce_register_additional_checkout_field() |
| Make postcode optional | woocommerce_checkout_fields |
Locale filters — woocommerce_get_country_locale + ..._default |
| Hide the company field | woocommerce_checkout_fields |
Checkout block editor sidebar (no code needed) |
| Reorder core fields | priority key in the field array |
Not supported — order is fixed by locale |
| Reposition your own field | priority key |
Choose a location — contact, address, or order |
Warning: the locale mismatch that lets people skip shipping
WooCommerce issued a developer advisory in May 2025 about exactly this. Sites that changed core address fields with woocommerce_checkout_fields, woocommerce_billing_fields or woocommerce_shipping_fields without updating the matching locale data ended up with a configuration mismatch — and in some cases shoppers could complete checkout without paying for shipping. If you make a core address field optional, hook both woocommerce_get_country_locale and woocommerce_get_country_locale_default so the field definition and the validation layer agree.
The replacement: woocommerce_register_additional_checkout_field()
This is the supported API for adding your own fields to the Checkout block, available since WooCommerce 8.9 (May 2024). If you are behind on updates, our guide to WooCommerce dropping PHP 7.4 and 8.0 covers the hosting side of catching up.
Step 1: decide where the code lives
Put it in a small site-specific plugin, not functions.php. Checkout fields are business logic, and you do not want them vanishing the day you switch themes.
Step 2: pick a location
Every field belongs to exactly one of three locations, and the location decides where it renders and where the value is stored.
| Location | Renders | Saved to | Good for |
|---|---|---|---|
contact |
Top of the form, with the email field | The customer account — editable in Account details | Marketing opt-in, phone preference |
address |
Inside both the shipping and billing forms | Customer and order — two separate values | VAT number, delivery instructions, gate code |
order |
The order information section | The order only | Delivery date, gift message, PO number |
A field can only be registered in one location. You cannot render the same field in two places in one registration — and an address field automatically produces a billing value and a shipping value, which surprises people the first time they read the data back.
Step 3: register the field
Register on the woocommerce_init action or later. Calling earlier fails, because WooCommerce has not built the registry yet. The ID must be namespaced as vendor/field-name.
add_action( 'woocommerce_init', function () {
woocommerce_register_additional_checkout_field(
array(
'id' => 'learnwoo/vat-number',
'label' => 'VAT number',
'location' => 'address',
'required' => false,
'attributes' => array(
'autocomplete' => 'off',
'pattern' => '[A-Z]{2}[A-Z0-9]{8,12}',
'title' => 'Two-letter country code followed by 8-12 characters',
),
)
);
} );
Supported attributes are data-*, aria-*, autocomplete, autocapitalize, pattern (not on checkboxes), title, maxLength and readOnly. Note the camelCase on the last two — they are passed to a React element, which insists on that spelling. autofocus and disabled are deliberately stripped.
Step 4: the other field types
// A dropdown in the order section.
woocommerce_register_additional_checkout_field(
array(
'id' => 'learnwoo/heard-about-us',
'label' => 'How did you hear about us?',
'placeholder' => 'Select a source',
'location' => 'order',
'type' => 'select',
'options' => array(
array( 'value' => 'search', 'label' => 'Search engine' ),
array( 'value' => 'social', 'label' => 'Social media' ),
array( 'value' => 'friend', 'label' => 'A friend' ),
),
)
);
// A tick box saved to the customer account.
woocommerce_register_additional_checkout_field(
array(
'id' => 'learnwoo/marketing-opt-in',
'label' => 'Email me about new products',
'location' => 'contact',
'type' => 'checkbox',
)
);
Select fields are the one place the API is noticeably thinner than the classic checkout: custom HTML attributes are not passed through to them yet.
Step 5: sanitise, then validate
These are two separate phases, in that order. Sanitising reshapes the input; validating judges it. Doing the judging inside the sanitiser is the most common structural mistake.
add_action( 'woocommerce_sanitize_additional_field', function ( $value, $key ) {
if ( 'learnwoo/vat-number' === $key ) {
$value = strtoupper( str_replace( ' ', '', $value ) );
}
return $value;
}, 10, 2 );
add_action( 'woocommerce_validate_additional_field', function ( WP_Error $errors, $key, $value ) {
if ( 'learnwoo/vat-number' === $key && '' !== $value ) {
if ( ! preg_match( '/^[A-Z]{2}[A-Z0-9]{8,12}$/', $value ) ) {
$errors->add( 'learnwoo_invalid_vat', 'Please check your VAT number format.' );
}
}
return $errors;
}, 10, 3 );
Prefix your error codes with your namespace. Codes are global; reuse one that another plugin already uses and your message can be overwritten or surface in the wrong part of the form.
To compare two fields against each other — a field and its confirmation, or a start date against an end date — use the per-location hook woocommerce_blocks_validate_location_address_fields (or ..._contact_fields / ..._order_fields), which receives every field in that group at once.
Step 6: read the value back
Values are not stored under the ID you registered. They get a group prefix so WooCommerce can query them without knowing the namespace: _wc_billing/, _wc_shipping/ and _wc_other/ (contact and order fields share the last one).
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
$order = wc_get_order( 1234 );
$fields = Package::container()->get( CheckoutFields::class );
$vat = $fields->get_field_from_object( 'learnwoo/vat-number', $order, 'billing' );
$all = $fields->get_all_fields_from_object( $order, 'other' );
Use the helper methods where you can — WooCommerce commits to migrating them if storage changes. Reach for the raw meta key only where you have no choice, which in practice means packing-slip and email builders that accept a meta key and nothing else. For those, the key is the prefix plus the full ID: _wc_billing/learnwoo/vat-number.
New in WooCommerce 11.2: proper date fields
Until now, “preferred delivery date” meant a text field and a lot of hope, or a plugin that shipped its own datepicker. WooCommerce 11.2 — in beta at the time of writing, with release week set for 6 October 2026 — adds date as a first-class field type that uses the browser’s native date picker, including the familiar wheel controls on mobile.
woocommerce_register_additional_checkout_field(
array(
'id' => 'learnwoo/delivery-date',
'label' => 'Preferred delivery date',
'location' => 'order',
'type' => 'date',
'required' => true,
'min' => 'P1D', // From tomorrow,
'max' => 'P30D', // up to 30 days out.
)
);
The interesting part is min and max. Each accepts either an absolute YYYY-MM-DD date or an ISO 8601-2 duration relative to today: P0D is today, P1D tomorrow, P2W two weeks out, -P18Y eighteen years ago. That last one turns a date of birth field into an age gate in a single line.






