How to Add Custom Checkout Fields to WooCommerce Block Checkout

Flat vector illustration of a checkout form gaining a new field, a PHP code block, and the value saved on the order record

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.

Date range shorthand, decoded

P0D — today
P1D — tomorrow (earliest realistic dispatch)
P2W — two weeks out
P3M — three months out
-P18Y — 18 years ago, as a max = age gate
PT1H — rejected: dates carry no time

Takeaway: pass the duration, not a date you calculated yourself.

That takeaway is the sharp edge. It is tempting to write 'min' => date( 'Y-m-d', strtotime( '+1 day' ) ). Don’t — that resolves once, at registration, so “tomorrow” can be stale by the time the shopper submits. The duration form is re-evaluated at render and again at submission, which is the only version that survives midnight.

Common mistakes

What goes wrong, and how it looks

Bad — silent failure

Registering before woocommerce_init. The call fails and the field never appears.

Bad — fatal error

Calling the function on WooCommerce below 8.9. Wrap it in function_exists() if you ship code to other people’s stores.

Warning — half the data

Reading an address field from only the billing group. It has a shipping value too.

Warning — vanishing guest data

Guest values live in the session until the order is placed. Don’t build pre-checkout logic on them.

Good — namespaced codes

Prefixing both field IDs and WP_Error codes with your own namespace.

Good — test both paths

Checking the field in the block checkout and in the classic fallback if you still offer one.

Takeaway: almost every “it doesn’t work” report is timing (registered too early) or version (below 8.9).

Limits and trade-offs worth knowing before you commit

You get four types, not a form builder. Text, select, checkbox and date. There is no file upload, no radio group, no multi-select. If your requirement is a signature pad, this API is not the answer.

Conditional logic exists, but it is declarative. The required and hidden options accept JSON Schema arrays as well as booleans, so you can show a field only when another field has a particular value — without writing any JavaScript. It is genuinely useful and genuinely more awkward than a drag-and-drop builder.

Display in the admin and in emails deserves a real test. Values are stored on the order and on the customer, and WooCommerce’s own documentation is explicit that the raw meta prefixes exist precisely so that “external engines or page/email builders” can read them. Before you go live, place a test order and check that the value appears where your team actually looks: the order edit screen, the packing slip, the new-order email.

Your field is part of a public API surface. Because the block reads its schema from the Store API, anything speaking that API sees your field too. That is an advantage for headless and app front ends, and a reason to sanitise properly rather than trusting the browser’s pattern attribute to do it for you.

For contrast: the same 11.2 release removes the experimental abandoned cart recovery and Agentic Checkout APIs outright, with no replacement endpoints. Additional checkout fields are the stable extension point. Experimental endpoints are not.

Code, or a plugin?

Write the code for two or three fields with real validation rules: it is forty lines, it lives in version control, and nothing can silently change it. Take a plugin for a dozen conditional fields, per-product rules, or a store maintained by someone who is not a developer — but verify on the plugin page that it supports the Checkout block specifically, because several are still classic-only. Several of the tools in our roundup of WooCommerce sales funnel builders and checkout plugins bundle a field editor.

What you should not do is switch back to the classic checkout to make an old snippet work. Compliance features are being built for the block first — the EU withdrawal button is the obvious recent example — and so are the block-native features store owners keep asking for, like variation image galleries in WooCommerce 11.1. Reverting buys you one afternoon and costs you the roadmap.

FAQ

How do I tell whether my store is on block or classic checkout?

Open the Checkout page in the WordPress editor. If you see a Checkout block with a settings sidebar, you are on blocks. If you see the shortcode [woocommerce_checkout] in a single paragraph, you are on classic.

Will my existing custom fields survive if I switch to the block checkout?

Fields added through woocommerce_checkout_fields will not render, and no new values will be collected. Values already saved on past orders stay exactly where they are — they are ordinary order meta. Re-register the fields with the new API before you switch, not after.

Can I make a field required only for certain countries or products?

For conditional requirements, use a JSON Schema array in required. For anything that depends on cart contents, do the check server-side in woocommerce_validate_additional_field or the per-location validation hook, where you can inspect the cart.

Do additional checkout fields work with guest checkout?

Yes. Guest values are held in the session and written to the order when it is placed. They just are not retained for a future visit, because there is no account to attach them to.

Is woocommerce_checkout_fields deprecated?

No — and this is worth being precise about. It is not deprecated, it still works on the classic checkout, and WooCommerce has not published a removal date for the classic shortcodes. It simply has no effect on the block. Treat it as a filter with a shrinking scope rather than a dead one.

What to do next

On staging, drop a single-file plugin into wp-content/plugins/ with one text field registered on woocommerce_init, and place a test order. Then check three places: the checkout form, the order edit screen, and the new-order email. That fifteen-minute loop tells you more about how this API behaves in your store — your theme, your gateway, your email plugin — than any amount of reading, and you will repeat it for every field you add.

And if the date field is the one you actually need, 11.2 lands the week of 6 October 2026 — worth putting on the calendar.