The Problem
A client ran a B2B WooCommerce store that required every order to carry a valid VAT number for orders over a threshold. The validation lived in woocommerce_after_checkout_validation, had been working for years, and was supposed to block checkout if the VAT lookup failed.
We migrated their store to the new checkout block during last month's WooCommerce 10.7 rollout. The validation stopped running. Customers without a VAT number were placing orders, and the only thing in the logs was a stack of successful payments.
The classic checkout still triggered the hook because we left it as the fallback on /checkout-classic. The block on /checkout did not. Same WordPress install, same plugin, same code path on paper, two completely different behaviours:
// This fired on the classic shortcode checkout. Silent on the block.
add_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {
if ( ! empty( $data['billing_company'] ) && empty( $data['vat_number'] ) ) {
$errors->add( 'vat_required', __( 'VAT number is required for company orders.', 'mytheme' ) );
}
}, 10, 2 );
If you migrated to the WooCommerce checkout block and your woocommerce_after_checkout_validation, woocommerce_checkout_process, or any classic WC()->session validation has gone quiet, you are looking at the same trap.
Why It Happens
The classic checkout posted a full form to admin-ajax.php, ran the standard WooCommerce checkout pipeline, and dispatched every legacy action along the way. Those hooks were part of the request lifecycle.
The block does not post the form. It uses the Store API. When the customer hits "Place order", the block sends a JSON payload to /wp-json/wc/store/v1/checkout, the Store API validates against its own schema, then creates the order through WC_Checkout::create_order() directly. The classic actions like woocommerce_after_checkout_validation were declared on the front-end form processor, which the Store API never touches.
So your hook is registered, it is just listening on a phone line nobody calls anymore. WooCommerce does not warn you because the hook is not deprecated. It still runs for the classic checkout. From the block's perspective, the order request is valid because the Store API's own schema passed, and your domain rule never had a chance to weigh in.
The Store API exposes a different extensibility surface called ExtendSchema. That is the hook the block actually hits before order creation, and it is documented in the Cart and Checkout Blocks reference.
The Fix
You need two things: a server-side validation that runs inside the Store API request, and a client-side error that prevents the order from being placed at all. The server-side check is mandatory because anyone can bypass the client.
Step 1: Register a Store API extension. Add this to a plugin or a mu-plugin (not the theme, since checkout extensions should not live in functions.php):
<?php
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
add_action( 'woocommerce_blocks_loaded', function () {
$extend = StoreApi::container()->get( ExtendSchema::class );
$extend->register_endpoint_data( [
'endpoint' => 'checkout',
'namespace' => 'mytheme/vat-required',
'schema_callback' => function () {
return [
'vat_number' => [
'description' => 'VAT number for B2B orders',
'type' => [ 'string', 'null' ],
'readonly' => true,
],
];
},
] );
} );
That registers your namespace inside the checkout endpoint's schema. The block now knows about a mytheme/vat-required extension and will send any field data you collect under that key.
Step 2: Validate inside the order processing pipeline. The Store API runs woocommerce_store_api_checkout_update_order_from_request just before the order is created. That is the equivalent of the classic validation hook:
add_action(
'woocommerce_store_api_checkout_update_order_from_request',
function ( \WC_Order $order, \WP_REST_Request $request ) {
$company = $order->get_billing_company();
if ( empty( $company ) ) {
return;
}
$extensions = $request['extensions'] ?? [];
$vat = $extensions['mytheme/vat-required']['vat_number'] ?? '';
if ( empty( $vat ) || ! mytheme_vat_lookup_is_valid( $vat ) ) {
throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
'mytheme_vat_required',
__( 'A valid VAT number is required for company orders.', 'mytheme' ),
400
);
}
},
10,
2
);
The RouteException is the important detail. Returning early or calling wp_die() will not surface a clean error to the block; the request will hang or 500. The exception with a 400 status is what the block reads and renders as a checkout error message right above the Place Order button.
Step 3: Stop relying on the classic hooks. If you still have woocommerce_checkout_process or woocommerce_after_checkout_validation doing the same work, leave them registered for any users still on the classic checkout, but treat the Store API handler as the source of truth. Duplicating logic is fine, since both code paths should reject the same invalid order.
Verify the fix. The fastest end-to-end check is to fire the Store API directly and watch the response:
curl -i -X POST https://yoursite.test/wp-json/wc/store/v1/checkout \
-H "Content-Type: application/json" \
-H "Nonce: $(curl -s https://yoursite.test/wp-json/wc/store/v1/cart | jq -r '.extensions["wc/store/nonce"]')" \
--data '{"billing_address":{"company":"ACME","first_name":"Test","last_name":"User","country":"GB"},"payment_method":"cod"}'
A successful block of the order returns a JSON body with code: "mytheme_vat_required" and HTTP 400. If you get a 200, the handler is not registered, usually because the plugin file did not load on the request, or woocommerce_blocks_loaded ran before your extension was on disk.
The Lesson
The checkout block has its own request pipeline that does not call the legacy form-processing hooks. If your validation runs on woocommerce_after_checkout_validation and you switch to the block, the validation is dead even though the hook still works elsewhere. Move blocking checks to woocommerce_store_api_checkout_update_order_from_request and raise a RouteException with a 400, and the block surfaces the message the way customers expect.
If your store migrated to the checkout block and something that used to be guaranteed has gone quiet (discounts, validations, custom fees), that is exactly the kind of migration I clean up for clients. See my services. For a related block-checkout extensibility issue, read WooCommerce checkout block custom field not saving.
Need block-checkout validation that actually blocks bad orders? Get in touch.