Pre-fill WooCommerce Checkout Fields With Data From Another Page

Quick Summary
- Save the values in the WooCommerce session when the visitor submits a form or opens a link with a URL parameter; for guests, start the session cookie first or WooCommerce doesn't keep them.
- For the name, email address and phone, set the values on WC()->customer: the Checkout block's Store API and the classic checkout both read the customer object.
- For a custom field, the classic checkout uses woocommerce_checkout_fields, woocommerce_checkout_get_value and woocommerce_checkout_create_order; the Checkout block uses woocommerce_register_additional_checkout_field() and a woocommerce_get_default_value_for_ filter.
To pre-fill the WooCommerce checkout with something a visitor entered earlier, save it in the WooCommerce session when they submit it, then hand it back when the checkout loads. For the standard fields (name, email address, phone), set the values on WC()->customer: both the classic checkout and the Checkout block read the customer object. For a custom field, the classic checkout takes its value from the woocommerce_checkout_get_value filter, and the Checkout block from a field registered with woocommerce_register_additional_checkout_field() and its woocommerce_get_default_value_for_{$key} filter.
The example collects a first name, last name, email address, phone number and a reference number (a quote, booking or member number, for example) in a form on another page. The reference can also come from a link such as /checkout/?reference=AB-1234. The code was checked against the WooCommerce 11.1.2 source and goes in your child theme’s functions.php or a small custom plugin.
Which checkout does your store use?
WooCommerce has two checkouts, and custom field code for one doesn’t work in the other. The classic checkout is the [woocommerce_checkout] shortcode; new stores get the Checkout block by default. The Checkout block loads its data through WooCommerce’s Store API and never runs the classic woocommerce_checkout_fields or woocommerce_checkout_get_value filters, and the classic checkout doesn’t print fields registered for the block. The post on changing the default checkout country shows how to tell which one you have.
Steps 1 and 2 below work for both checkouts. Step 3 is for the classic checkout and step 4 for the Checkout block, so you only need the one that matches your store.
Step 1: Save what the visitor entered in the WooCommerce session
The shortcode prints the form, so you can place it on any page with [nmh47_prefill_form]. The second function runs when the form is submitted, or when someone arrives with ?reference= in the URL.
/**
* Step 1a: the form. Put [nmh47_prefill_form] on any page.
*/
add_shortcode( 'nmh47_prefill_form', 'nmh47_prefill_form' );
function nmh47_prefill_form() {
ob_start();
?>
<form method="post" class="nmh47-prefill-form">
<?php wp_nonce_field( 'nmh47_prefill', 'nmh47_prefill_nonce' ); ?>
<p><label for="nmh47_first_name">First name</label>
<input type="text" id="nmh47_first_name" name="nmh47_first_name" autocomplete="given-name"></p>
<p><label for="nmh47_last_name">Last name</label>
<input type="text" id="nmh47_last_name" name="nmh47_last_name" autocomplete="family-name"></p>
<p><label for="nmh47_email">Email address</label>
<input type="email" id="nmh47_email" name="nmh47_email" autocomplete="email"></p>
<p><label for="nmh47_phone">Phone</label>
<input type="tel" id="nmh47_phone" name="nmh47_phone" autocomplete="tel"></p>
<p><label for="nmh47_reference">Reference number</label>
<input type="text" id="nmh47_reference" name="nmh47_reference"></p>
<p><button type="submit">Continue</button></p>
</form>
<?php
return ob_get_clean();
}
/**
* Step 1b: save the values in the WooCommerce session.
*/
add_action( 'template_redirect', 'nmh47_save_prefill', 5 );
function nmh47_save_prefill() {
if ( ! function_exists( 'WC' ) || ! WC()->session ) {
return;
}
$values = array();
$from_form = isset( $_POST['nmh47_prefill_nonce'] )
&& wp_verify_nonce( sanitize_key( $_POST['nmh47_prefill_nonce'] ), 'nmh47_prefill' );
if ( $from_form ) {
$email = sanitize_email( wp_unslash( $_POST['nmh47_email'] ?? '' ) );
$values = array(
'first_name' => sanitize_text_field( wp_unslash( $_POST['nmh47_first_name'] ?? '' ) ),
'last_name' => sanitize_text_field( wp_unslash( $_POST['nmh47_last_name'] ?? '' ) ),
'email' => is_email( $email ) ? $email : '',
'phone' => sanitize_text_field( wp_unslash( $_POST['nmh47_phone'] ?? '' ) ),
'reference' => sanitize_text_field( wp_unslash( $_POST['nmh47_reference'] ?? '' ) ),
);
} elseif ( isset( $_GET['reference'] ) && is_string( $_GET['reference'] ) ) {
// A link such as https://example.com/checkout/?reference=AB-1234
$values['reference'] = sanitize_text_field( wp_unslash( $_GET['reference'] ) );
}
$values = array_filter( $values, 'strlen' );
if ( ! $values ) {
return;
}
// A guest has no session cookie until they add to the cart. Without one, WooCommerce doesn't save the session.
if ( ! WC()->session->has_session() ) {
WC()->session->set_customer_session_cookie( true );
}
$saved = (array) WC()->session->get( 'nmh47_prefill', array() );
WC()->session->set( 'nmh47_prefill', array_merge( $saved, $values ) );
if ( $from_form ) {
// Redirect after saving, so refreshing the next page doesn't post the form again.
wp_safe_redirect( WC()->cart->is_empty() ? wc_get_page_permalink( 'shop' ) : wc_get_checkout_url() );
exit;
}
}- The nonce.
wp_nonce_field()adds a hidden token andwp_verify_nonce()checks it, so another site can’t submit the form for your visitors. A nonce expires after a day at most, so keep the page with the form out of your page cache. - Sanitizing. Request data is unslashed (WordPress adds slashes to it) and cleaned:
sanitize_text_field()strips tags and line breaks, and the email address is only kept whenis_email()accepts it, because WooCommerce’s customer object throws an error for an invalid billing email. - Field names have an
nmh47_prefix because WordPress reads some POST fields, such asname, as query variables, which changes which page loads. - The session cookie. WooCommerce only saves the session when the visitor has a session cookie or is logged in, and guests normally get the cookie when they add something to the cart.
set_customer_session_cookie( true )starts it earlier. It sets a cookie, so it must run before any output, astemplate_redirectdoes. - Priority 5 runs before WooCommerce’s own
template_redirectcallback (priority 10), which sends visitors with an empty cart from the checkout to the cart page. A?reference=link to the checkout is saved even then. - The redirect goes to the checkout, or to the shop page when the cart is empty. Change it to suit your flow, for example to a product page.
Put personal details (name, email, phone) only in a form, never in a link: URLs end up in server logs, browser history and analytics. A URL parameter is fine for a value like a reference code.
Step 2: Fill the standard fields on both checkouts
The Checkout block gets its addresses from WooCommerce’s Store API, which reads them from WC()->customer. The classic checkout reads the same object for guests; for logged-in customers it loads their account and applies the same session data on top. Setting the values on the customer once fills both.
/**
* Step 2: copy the standard fields to the customer (both checkouts).
*/
add_action( 'template_redirect', 'nmh47_prefill_customer', 20 );
function nmh47_prefill_customer() {
if ( ! function_exists( 'WC' ) || ! is_checkout() || ! WC()->session || ! WC()->customer ) {
return;
}
$prefill = (array) WC()->session->get( 'nmh47_prefill', array() );
$customer = WC()->customer;
$changed = false;
$fields = array(
'first_name' => array( 'billing_first_name', 'shipping_first_name' ),
'last_name' => array( 'billing_last_name', 'shipping_last_name' ),
'phone' => array( 'billing_phone', 'shipping_phone' ),
'email' => array( 'billing_email' ),
);
foreach ( $fields as $key => $props ) {
foreach ( $props as $prop ) {
// Only fill empty fields, so a saved address isn't overwritten.
if ( ! empty( $prefill[ $key ] ) && '' === $customer->{"get_$prop"}() ) {
$customer->{"set_$prop"}( $prefill[ $key ] );
$changed = true;
}
}
}
if ( $changed ) {
$customer->save(); // Saves to the WooCommerce session, not to the user's account.
}
}WC()->customeris session-based:save()writes to the WooCommerce session, not to the user’s account. The details reach the account only if the customer places the order.- A field is only filled when it’s empty, so a logged-in customer’s saved address wins. The Checkout block saves the customer’s own changes to the same customer object as they go, so those win too.
- The name and phone go on the shipping address as well as the billing address. When the Checkout block loads, it compares the two addresses; if they differ, it starts with “Use same address for billing” unticked and shows a second address form.
- For other address fields, such as the company or the country, use the matching setters, for example
set_billing_company(). To change only the default country, a filter is simpler: see changing the default state and country.
Step 3 (classic checkout): Add the custom field, fill it and save it
The reference number isn’t a WooCommerce field, so the classic checkout needs three parts: the field, its value and the code that saves it to the order.
/**
* Step 3 (classic checkout): add the field, fill it and save it to the order.
*/
add_filter( 'woocommerce_checkout_fields', 'nmh47_add_reference_field' );
function nmh47_add_reference_field( $fields ) {
$fields['billing']['nmh47_reference'] = array(
'type' => 'text',
'label' => 'Reference number',
'required' => false,
'class' => array( 'form-row-wide' ),
'priority' => 120,
);
return $fields;
}
add_filter( 'woocommerce_checkout_get_value', 'nmh47_reference_checkout_value', 10, 2 );
function nmh47_reference_checkout_value( $value, $input ) {
if ( 'nmh47_reference' === $input && WC()->session ) {
$prefill = (array) WC()->session->get( 'nmh47_prefill', array() );
if ( ! empty( $prefill['reference'] ) ) {
return $prefill['reference'];
}
}
return $value;
}
add_action( 'woocommerce_checkout_create_order', 'nmh47_save_reference_field', 10, 2 );
function nmh47_save_reference_field( $order, $data ) {
if ( ! empty( $data['nmh47_reference'] ) ) {
$order->update_meta_data( '_nmh47_reference', $data['nmh47_reference'] );
}
}
add_action( 'woocommerce_admin_order_data_after_billing_address', 'nmh47_show_reference_field' );
function nmh47_show_reference_field( $order ) {
$reference = $order->get_meta( '_nmh47_reference' );
if ( $reference ) {
echo '<p><strong>Reference number:</strong> ' . esc_html( $reference ) . '</p>';
}
}Adding the field
woocommerce_checkout_fields receives every checkout field, grouped into billing, shipping, account and order. The billing group is always shown; a priority of 120 puts the field after the email address (110). The same filter changes existing fields, for example the order notes label.
The checkout template prints each field with woocommerce_form_field( $key, $field, $checkout->get_value( $key ) ). woocommerce_form_field() escapes the value with esc_attr(), so the pre-filled text can’t break the HTML. If you print a field yourself on a hook such as woocommerce_after_order_notes, pass the value as the third argument in the same way, and save it from $_POST, because WooCommerce only collects the fields in woocommerce_checkout_fields.
Filling it: woocommerce_checkout_get_value or default_checkout_{$field}?
WC_Checkout::get_value() decides the value of every classic checkout field, in this order:
- The posted value, when the form was submitted without JavaScript.
- The
woocommerce_checkout_get_valuefilter. It receivesnulland the field name; return anything other thannulland WooCommerce uses it straight away. - The customer’s data: for a logged-in customer, the saved account with any changes from the session; for a guest, the session. For a field that isn’t a standard property, a customer meta value with the same key.
- The
default_checkout_{$field}filter, for exampledefault_checkout_billing_phone. It receives the value from step 3 (nullif empty) and the field name, and whatever it returns is used.
For the reference number both filters work, because there’s no saved value to respect. For a standard field the difference matters: woocommerce_checkout_get_value overrides a logged-in customer’s saved details, while default_checkout_billing_phone can check null === $value and only fill the gap.
Saving it to the order
WooCommerce cleans each posted text field with wc_clean() (which uses sanitize_text_field()) and passes it to woocommerce_checkout_create_order in $data. It only saves a custom field by itself when the key starts with billing_ or shipping_, so this one needs update_meta_data(). The order is saved right after the action, so there’s no $order->save(), and update_meta_data() and get_meta() work with posts and HPOS order storage alike. The last function shows the value on the order screen in the admin, escaped with esc_html().
To make the field required, set 'required' => true. For other checks, add errors in the woocommerce_after_checkout_validation action, which receives $data and a WP_Error object.
Step 4 (Checkout block): Register the field with the Additional Checkout Fields API
For the Checkout block, WooCommerce 8.9 and later have an API for extra fields. You register the field, and WooCommerce shows it, validates it and saves it with the order.
/**
* Step 4 (Checkout block): register the field and give it a default value.
*/
add_action( 'woocommerce_init', 'nmh47_register_reference_field' );
function nmh47_register_reference_field() {
if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
return; // WooCommerce older than 8.9.
}
woocommerce_register_additional_checkout_field(
array(
'id' => 'nmh47/reference',
'label' => 'Reference number',
'location' => 'order',
'type' => 'text',
'required' => false,
'sanitize_callback' => function ( $value ) {
return sanitize_text_field( $value );
},
)
);
}
add_filter( 'woocommerce_get_default_value_for_nmh47/reference', 'nmh47_reference_default', 10, 3 );
function nmh47_reference_default( $value, $group, $wc_object ) {
// Only while an order is being placed, not for orders that were placed without a reference.
if ( $wc_object instanceof WC_Order && ! $wc_object->has_status( 'checkout-draft' ) ) {
return $value;
}
$prefill = WC()->session ? (array) WC()->session->get( 'nmh47_prefill', array() ) : array();
return ! empty( $prefill['reference'] ) ? $prefill['reference'] : $value;
}- Register on
woocommerce_init. WooCommerce’s documentation says to register fields on this action or later. Since WooCommerce 11.0, registering beforeafter_setup_themetriggers a “doing it wrong” notice. idis a namespace and a name separated by a slash.locationorderputs the field in the block’s order information section and saves it to the order only;contactfields are also saved to the customer’s account, andaddressfields appear in both addresses. In WooCommerce 11.1 the types aretext,selectandcheckbox.sanitize_callbackcleans the submitted value. Without one, WooCommerce keeps it unchanged. Avalidate_callbackthat returns aWP_Errorrejects a value.- The default value. The filter name ends with the full field ID, slash included. It receives
null, the group (billing,shippingorother) and the order or customer object, and WooCommerce only calls it when that object has no value yet, so whatever the customer types wins. WooCommerce also calls it when it shows a placed order without a reference; returning early for orders that aren’t drafts stops an old order from showing the reference in the current session. - Saving is automatic: the value is stored as order meta under
_wc_other/nmh47/reference, and WooCommerce shows it on the order edit screen and in the customer’s order details. In your own code, read it with$order->get_meta( '_wc_other/nmh47/reference' ).
Things to check
- Test as a new visitor. Use a private window. A logged-in test account has saved details, and step 2 doesn’t overwrite them.
- How long the values last. They stay in the session until it expires, after 2 days for guests and a week for logged-in customers by default. If a second order in the same session shouldn’t be pre-filled, clear them once the order is created:
add_action( 'woocommerce_checkout_order_created', 'nmh47_clear_prefill' ); // Classic checkout.
add_action( 'woocommerce_store_api_checkout_order_processed', 'nmh47_clear_prefill' ); // Checkout block.
function nmh47_clear_prefill() {
if ( WC()->session ) {
WC()->session->__unset( 'nmh47_prefill' );
}
}If the first page uses a form plugin, save the values in the plugin’s own after-submission hook instead of the $_POST part of step 1; the session key and steps 2 to 4 stay the same.
Related articles
- changing the default state and country on the checkout: the other way to start the checkout with a value, for both checkouts
- changing the order notes text: another use of
woocommerce_checkout_fields - adding and removing a classic checkout action: how the classic checkout hooks work
