

WooCommerce custom checkout fields are not finished when they appear on the page. They also have to survive submission, land on the order, and reject only data that would make fulfilment impossible. Miss the save hook and the customer completes checkout while the value disappears. Make validation too rigid and good orders fail at the last step.
This guide covers the full path with practical code, including the different rules for Checkout blocks and express payment buttons.
On the classic checkout, this filter gives you the complete form as a nested array before WooCommerce renders it. Use it to change core fields.
woocommerce_checkout_fields returns four top-level groups. Each group contains fields keyed by input name. Each field is an array of properties such as label, placeholder, required, class, priority, and validate.
The four field groups in $fields
| Group | Holds | Rendered when |
|---|---|---|
billing | Billing name, address, phone and email | Always |
shipping | Shipping name and address | The cart needs shipping and the customer uses another address |
account | Username and password fields | Account creation at checkout is enabled |
order | Order notes and, by convention, your own order-level fields | Always, unless you remove it |
Core priorities usually increase in tens, leaving useful gaps for your own fields.
Two boundaries catch experienced developers too. This filter affects only the classic checkout shortcode, not the Checkout block. It also controls rendering and required state only. Without a save hook, a field added here loses its value after checkout. After a WooCommerce update, confirm the field still renders and saves before you assume checkout is healthy.
Put this in a small site-specific plugin or child theme. Do not put it in a parent theme:
add_filter( 'woocommerce_checkout_fields', 'sm_checkout_fields' );
function sm_checkout_fields( $fields ) {
$fields['billing']['billing_phone']['required'] = false;
$fields['billing']['billing_phone']['label'] = 'Mobile number for delivery updates';
$fields['order']['order_comments']['placeholder'] = 'Anything the courier should know';
return $fields;
}Always return $fields. A filter that returns nothing gives WooCommerce an empty checkout form and can take a live store offline.
Address fields also pass through woocommerce_billing_fields, woocommerce_shipping_fields, and woocommerce_default_address_fields. Use those hooks when the same change must apply to My Account address forms.
Unset a key to drop a field, and set priority to move it:
add_filter( 'woocommerce_checkout_fields', 'sm_trim_checkout_fields' );
function sm_trim_checkout_fields( $fields ) {
// Drop fields this store genuinely does not use.
unset( $fields['billing']['billing_company'] );
unset( $fields['billing']['billing_address_2'] );
// Move contact details above the address block.
$fields['billing']['billing_email']['priority'] = 21;
$fields['billing']['billing_phone']['priority'] = 22;
// Widen the remaining rows.
$fields['billing']['billing_email']['class'] = array( 'form-row-wide' );
return $fields;
}Priority controls order within one group, not across the page. Reordering billing fields does not affect shipping. Core uses gaps of ten, so 21 and 22 place both fields between last name (20) and company (30) without moving anything else.
Reordering is low risk. Removing fields requires a business reason.
Tax and shipping fields
Keep these fields even if they look expendable:
For the classic WooCommerce checkout, render a custom field with woocommerce_form_field() on a checkout action hook. woocommerce_after_order_notes is a common location. woocommerce_before_order_notes, woocommerce_after_checkout_billing_form, and woocommerce_review_order_before_submit follow the same pattern. Prefix every field name to avoid collisions with WooCommerce or plugins.
add_action( 'woocommerce_after_order_notes', 'sm_text_checkout_fields' );
function sm_text_checkout_fields( $checkout ) {
woocommerce_form_field(
'sm_vat_number',
array(
'type' => 'text',
'label' => __( 'VAT number', 'my-store' ),
'placeholder' => 'DE123456789',
'class' => array( 'form-row-wide' ),
'required' => false,
),
$checkout->get_value( 'sm_vat_number' )
);
woocommerce_form_field(
'sm_delivery_notes',
array(
'type' => 'textarea',
'label' => __( 'Delivery notes', 'my-store' ),
'class' => array( 'form-row-wide' ),
),
$checkout->get_value( 'sm_delivery_notes' )
);
}$checkout->get_value() restores the input when validation returns the customer to the form. Without it, every failed submission clears what they entered.
Both field types use an options array keyed by the value you intend to store:
woocommerce_form_field( 'sm_delivery_window', array( 'type' => 'select', 'label' => __( 'Preferred delivery window', 'my-store' ), 'class' => array( 'form-row-wide' ), 'options' => array( '' => __( 'No preference', 'my-store' ), 'morning' => __( 'Morning, 8:00 to 12:00', 'my-store' ), 'evening' => __( 'Evening, 16:00 to 20:00', 'my-store' ), ), ), $checkout->get_value( 'sm_delivery_window' ) );
Replace select with radio and WooCommerce renders the same options as radio inputs. Treat submitted values as untrusted. They are request strings, not proof that the customer selected a valid option. Compare each value with your own allowed list before saving it.
woocommerce_form_field( 'sm_gift_wrap', array( 'type' => 'checkbox', 'label' => __( 'Add gift wrapping', 'my-store' ), 'class' => array( 'form-row-wide' ), ), $checkout->get_value( 'sm_gift_wrap' ) );
An unchecked checkbox submits nothing. Test with isset() instead of comparing a value, then store an explicit yes or no. Otherwise you cannot distinguish a declined option from a field that never rendered.
File uploads are the exception. The classic checkout submits a serialized form over AJAX, and serialized forms do not include $_FILES. A plain type => file input can look correct while reaching the server empty.
Use two requests. Upload the file to your own endpoint when the customer selects it. Then place the returned attachment ID in a hidden field that the checkout submission can carry.
add_action( 'wp_ajax_sm_upload_checkout_file', 'sm_upload_checkout_file' );
add_action( 'wp_ajax_nopriv_sm_upload_checkout_file', 'sm_upload_checkout_file' );
function sm_upload_checkout_file() {
check_ajax_referer( 'sm_checkout_upload', 'nonce' );
if ( empty( $_FILES['sm_artwork']['name'] ) ) {
wp_send_json_error( array( 'message' => 'No file received.' ), 400 );
}
if ( $_FILES['sm_artwork']['size'] > 2 * MB_IN_BYTES ) {
wp_send_json_error( array( 'message' => 'Maximum file size is 2 MB.' ), 400 );
}
// Whitelist, not blacklist. No SVG, no archives, nothing executable.
$allowed = array(
'pdf' => 'application/pdf',
'png' => 'image/png',
'jpg|jpeg' => 'image/jpeg',
);
$checked = wp_check_filetype_and_ext(
$_FILES['sm_artwork']['tmp_name'],
sanitize_file_name( $_FILES['sm_artwork']['name'] ),
$allowed
);
if ( empty( $checked['type'] ) ) {
wp_send_json_error( array( 'message' => 'File type not allowed.' ), 400 );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$attachment_id = media_handle_upload(
'sm_artwork',
0,
array(),
array(
'test_form' => false,
'mimes' => $allowed,
)
);
if ( is_wp_error( $attachment_id ) ) {
wp_send_json_error( array( 'message' => $attachment_id->get_error_message() ), 400 );
}
wp_send_json_success( array( 'attachment_id' => (int) $attachment_id ) );
}Your JavaScript sends the file and nonce to admin-ajax.php, then writes attachment_id to a hidden sm_artwork_id input. The save hook converts that ID to an integer and verifies that it references an acceptable attachment.
Public upload endpoint
A rendered field without a save hook captures nothing. Checkout still completes, which is why this failure can stay invisible.
Save through the order object on woocommerce_checkout_create_order. It runs before WooCommerce writes the order:
add_action( 'woocommerce_checkout_create_order', 'sm_save_custom_checkout_fields', 10, 2 );
function sm_save_custom_checkout_fields( $order, $data ) {
// WooCommerce has already verified its own checkout nonce at this point.
if ( ! empty( $_POST['sm_vat_number'] ) ) {
$order->update_meta_data(
'_sm_vat_number',
sanitize_text_field( wp_unslash( $_POST['sm_vat_number'] ) )
);
}
if ( ! empty( $_POST['sm_delivery_notes'] ) ) {
$order->update_meta_data(
'_sm_delivery_notes',
sanitize_textarea_field( wp_unslash( $_POST['sm_delivery_notes'] ) )
);
}
// Never trust a posted option value. Compare it against your own list.
$window = isset( $_POST['sm_delivery_window'] ) ? sanitize_key( wp_unslash( $_POST['sm_delivery_window'] ) ) : '';
$allowed = array( 'morning', 'evening' );
if ( in_array( $window, $allowed, true ) ) {
$order->update_meta_data( '_sm_delivery_window', $window );
}
// An unchecked checkbox posts nothing, so write both states explicitly.
$order->update_meta_data( '_sm_gift_wrap', isset( $_POST['sm_gift_wrap'] ) ? 'yes' : 'no' );
}Do not call $order->save() here. WooCommerce saves the order immediately after this hook. Using the order object instead of update_post_meta() keeps the code compatible with HPOS order storage, where the posts table is no longer the authoritative order store.
If you inherit code on the older woocommerce_checkout_update_order_meta hook, you do not need to replace it immediately. You do need to use the CRUD layer:
add_action( 'woocommerce_checkout_update_order_meta', 'sm_legacy_save_custom_fields' );
function sm_legacy_save_custom_fields( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || empty( $_POST['sm_vat_number'] ) ) {
return;
}
$order->update_meta_data( '_sm_vat_number', sanitize_text_field( wp_unslash( $_POST['sm_vat_number'] ) ) );
$order->save();
}Meta keys that begin with an underscore stay hidden in the generic custom-fields panel. Render them explicitly:
add_action( 'woocommerce_admin_order_data_after_billing_address', 'sm_show_custom_fields_in_admin' );
function sm_show_custom_fields_in_admin( $order ) {
$vat = $order->get_meta( '_sm_vat_number' );
$window = $order->get_meta( '_sm_delivery_window' );
if ( $vat ) {
echo '<p><strong>' . esc_html__( 'VAT number', 'my-store' ) . ':</strong> ' . esc_html( $vat ) . '</p>';
}
if ( $window ) {
echo '<p><strong>' . esc_html__( 'Delivery window', 'my-store' ) . ':</strong> ' . esc_html( $window ) . '</p>';
}
}Use $order->get_meta() rather than get_post_meta(), for the same HPOS compatibility reason as the save hook.
This filter appends your fields to the meta table in WooCommerce order emails:
add_filter( 'woocommerce_email_order_meta_fields', 'sm_email_order_meta_fields', 10, 3 );
function sm_email_order_meta_fields( $fields, $sent_to_admin, $order ) {
$vat = $order->get_meta( '_sm_vat_number' );
if ( $vat ) {
$fields['sm_vat_number'] = array(
'label' => __( 'VAT number', 'my-store' ),
'value' => $vat,
);
}
return $fields;
}Check $sent_to_admin when a value belongs only in the internal email. This filter controls template output. It does not confirm that an email reached an inbox.
Validation is where a form change starts to affect completed orders.
Use woocommerce_after_checkout_validation. It runs after core validation, gives you parsed $data and a WP_Error object, and lets you connect an error to one field:
add_action( 'woocommerce_after_checkout_validation', 'sm_validate_custom_checkout_fields', 10, 2 );
function sm_validate_custom_checkout_fields( $data, $errors ) {
$vat = isset( $_POST['sm_vat_number'] ) ? sanitize_text_field( wp_unslash( $_POST['sm_vat_number'] ) ) : '';
if ( '' === $vat ) {
return;
}
// Normalize before judging: people paste spaces and dots.
$vat = strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', $vat ) );
if ( ! preg_match( '/^[A-Z]{2}[A-Z0-9]{2,12}$/', $vat ) ) {
$errors->add(
'sm_vat_number',
__( 'Enter your VAT number with the country prefix, for example DE123456789. Leave it empty if you do not have one.', 'my-store' )
);
}
}The older woocommerce_checkout_process hook with wc_add_notice() still works. It cannot attach an error to a field, so the customer sees a page-level message without knowing which input needs attention.
Overly strict validation is expensive because it rarely creates an error log or a support ticket. The customer sees a rejection they cannot resolve and leaves.
Silent validation failures
Before making a custom field required, check four things:
Keep the field optional unless the order genuinely cannot be fulfilled without it.
Code gives you precise control. A plugin gives non-developers an admin interface. That trade-off matters more than many teams expect.
The official Checkout Field Editor extension provides a table for billing, shipping, and additional fields. You can add, remove, reorder, relabel, and change types such as select, radio, checkbox, and date. ThemeHigh's Checkout Field Editor offers similar controls, with a free tier and conditional logic in its paid version. Both store field definitions in the options table, outside version control. Those definitions also stop applying when the plugin is deactivated.
Code vs plugin decision
| Situation | Better choice |
|---|---|
| One or two stable fields with one save path | Code |
| Non-technical staff change fields regularly | Field editor plugin |
| Conditional fields need changing without a deploy | Field editor plugin |
| Custom validation or an external lookup | Code |
| Field values must feed another system | Code |
The plugin route wins when:
Custom fields depend on filters and order storage. That makes them vulnerable to updates you did not write.
Validation Blocking Legitimate Orders
Fields Not Saving After a WooCommerce Update
Block Checkout Incompatibility
Conflicts with Express Checkout
A checkout can look healthy while a field no longer saves. ShopMonitor runs the browser checkout and thank-you path on a schedule, so a failed path is visible in the next test run. It verifies the browser path only. It does not test inbox delivery or email receipt.
Use a repeatable checkout pass after every WooCommerce, theme, and plugin update. Submit every custom field and confirm that its value reached the order. Our guide to test the checkout covers the manual process. The wider set of changes you may make to customize the checkout page needs the same discipline.
ShopMonitor runs browser-based checkout and form tests on your schedule, then alerts you when the path fails. No plugin required.
No credit card required.

About the author
Written by the team at Winning Solutions, the agency behind ShopMonitor. We help WooCommerce teams build checkout fields that save reliably and validate without blocking legitimate orders.