.ai/skills/woocommerce-store-api/variation-handling.md
Variable products and their variations are the most error-prone surface in the Store API. A request can take several shapes depending on the UX, the server has to reconcile the client's claim about which variation is being referenced, and the same logical variation can be expressed multiple ways. Get any of that wrong and you store ambiguous data, break idempotency, or return 500s on routine input.
This page documents the cart's handling so any new route that accepts variation references can mirror it.
A typical Store API request looks like:
{ "id": 99, "variation": { "attribute_pa_color": "blue" } }
Two things are claimed: variation 99 exists, and its colour is blue. The first is verifiable; the second is not, unless you check. A client (malicious or buggy) can send id: 99 with variation: { color: red } while variation 99 is actually blue — and a route that trusts the client verbatim will store the wrong attributes.
This produces two downstream problems:
The fix is to derive canonical attributes from the variation product itself. Treat the client's payload as a hypothesis, not a source of truth.
WooCommerce front-end UX produces two shapes of variation input, both of which the cart accepts:
Variation ID (resolved upstream).
{ "id": 99, "variation": { "attribute_pa_color": "blue" } }
The client has already picked a specific variation; id is its post ID. The variation array is a claim about its attributes that the server validates.
Variable parent + attributes (resolved by the server).
{ "id": 42, "variation": [ {"attribute": "pa_color", "value": "blue"}, {"attribute": "pa_size", "value": "medium"} ] }
This is what the standard product page posts: id is the variable parent product, and the user's dropdown selections come along as the variation array. The server resolves to the matching variation via WC_Data_Store::find_matching_product_variation().
Simple (non-variable) products have variation = [] and skip the reconciliation entirely.
CartController::parse_variation_data() is the canonical pattern. The flow:
variation and return.attribute_<slug> form so all comparisons run against the same vocabulary.get_variation_id_from_variation_data() looks up which variation matches the posted attributes.wc_get_product_variation_attributes( $id ) returns the variation's expected slug for each variable attribute (or '' for an "Any" slot).ksort the resulting variation array so the same logical variation always serialises to the same key — important for idempotency keys hashed from this array.Each variable attribute on a variation is either a specific value (the variation pins pa_color = blue) or "Any" (the variation accepts any of the parent's allowed values for pa_size). They need different handling:
WC_Product_Attribute::get_slugs()), otherwise 400.wc_get_product_variation_attributes() returns slugs like [ 'attribute_pa_color' => 'blue', 'attribute_pa_size' => '' ]. The empty string is the marker for "Any".
WooCommerce stores attribute values as lowercase taxonomy term slugs. Both sides of any comparison come from canonicalised storage, so use strict ===/!==. Don't add case-insensitive matching — the cart doesn't, and consistency matters for predictable client behaviour.
Direct API clients (using id + variation) need to send slugs in their canonical form. The cart already canonicalises on its way into cart_contents, so server-side round-trips are clean.
Validation failures should throw RouteException directly with a 400 status. The Store API framework catches it and returns a structured error response — no wrapper or translation layer needed:
throw new RouteException(
'woocommerce_rest_invalid_variation_data',
sprintf(
/* translators: %1$s: Attribute name, %2$s: Allowed values. */
esc_html__( 'Invalid value posted for %1$s. Allowed values: %2$s', 'woocommerce' ),
esc_html( $attribute_label ),
esc_html( implode( ', ', $attribute->get_slugs() ) )
),
400
);
The cart uses two error codes for variation issues:
woocommerce_rest_invalid_variation_data — a posted attribute has a value the variation doesn't accept. Surface the allowed values in the message.woocommerce_rest_missing_variation_data — an "Any" attribute wasn't posted. Surface the missing attribute label.Both return 400. Don't throw a generic \InvalidArgumentException — exceptions that aren't RouteException fall through to the abstract route's generic handler, which returns a 500 with woocommerce_rest_unknown_server_error and obscures the real problem from the client.
Any route that accepts variation references should have tests for:
The variation path is where future regressions are most likely to land. Tests are the only durable lock on the reconciliation behaviour.
CartController::parse_variation_data() — the canonical reconciliation pattern; mirror this in any new route that accepts variation references.CartController::get_variation_id_from_variation_data() — resolves a variable parent + posted attributes to a specific variation ID.wc_get_product_variation_attributes() (WooCommerce core) — returns canonical slugs for a variation, with '' for "Any" slots.WC_Product_Attribute::get_slugs() (WooCommerce core) — returns the allowed slug list for an attribute on the parent product.WC_Data_Store::find_matching_product_variation() (WooCommerce core) — underlying lookup used by get_variation_id_from_variation_data().