This page is for developers who adjust or extend Bespoke Product Options with code: add field types, change where and how fields render, hook into the pricing, use the JavaScript events, call the REST API and read the stored data. Store owners find the setup and every feature in the user documentation.
Conventions: PHP code lives in the BespokeProductOptions namespace under src/, templates under views/. Every hook is prefixed with bespoke_product_options/ and uses / as a separator. Options and handles are prefixed with bpo_ and bpo-, CSS classes with bpo-. The storefront script still uses the spa: event prefix and the document.__spaAddons global, which date from an earlier name of the plugin. Snippets below belong in a small companion plugin or in the theme’s functions.php; none of them requires editing plugin files.
Data model
↑ Back to topEverything is stored in standard WordPress primitives, no custom tables, so groups survive exports, search and replace migrations and the usual backup tools.
| Storage | What it holds |
|---|---|
bpo_addon_group post type | One post per option group. The title is the group name; the post status is the Active toggle (publish = active, draft = inactive). |
| Group post meta | _bpo_fields, _bpo_assignment, _bpo_visibility, _bpo_appearance, _bpo_cart, each a JSON string with the array you see as a builder panel. |
bpo_option_set post type | Reusable choice sets; the rows live in _bpo_options. |
| Product meta | Product-specific options: _bpo_product_fields, _bpo_product_appearance, _bpo_product_cart, and _bpo_product_status which is only present when the product fields are inactive (draft). |
| Cart item | Selections travel under the bpo_addons key as a list of normalised entries. |
| Order item meta | One visible meta row per entry (label to display value with the price hint), plus a machine readable copy of all entries in _bpo_addons. _bpo_base_price is written by the order screen editor. |
A single cart or order entry is a plain array:
array(
'field_id' => 'f1a2b3c4-…',
'type' => 'input',
'name' => 'Embroidered name',
'value' => 'EMMA',
'display' => 'EMMA',
'price' => 0.5,
'price_type' => 'char', // flat | percent | qty | char | area | per_day
// optional: image, color (swatches), area + unit (dimensions), days (date range), no_convert (customer defined price)
)
Field definitions use the key names of the builder: id, type, label, labelFormat, description, required, position, conditionalLogic, variations, then per type price, priceType, options, defaultOption, display, multiple, swatchShape, inputMode, minLength, maxLength, minChoices, maxChoices, minQty, maxQty, unit, minDate, maxDate, minDays, maxDays, acceptedTypes, maxFileSize, multipleFiles, checkboxLabel, defaultChecked. Type slugs are select, checkbox, input, textarea, quantity, dimensions, date, date_range, file, custom_price and heading. Display modes are dropdown, buttons (radio or checkboxes), pills (buttons), cards, images and colors. Conditional logic is { enabled, action: show|hide, match: all|any, rules: [ { field, relation, value } ] }.
Global settings are regular options: bpo_theme, bpo_max_file_size, bpo_allowed_file_types, bpo_upload_retention_days, bpo_delete_data_on_uninstall, and one bpo_integration_{slug} switch per integration.
Custom field types
↑ Back to topRegister a type through bespoke_product_options/field_types and it appears in the builder’s Type dropdown with its label, description, price switch and settings. The icon key names a @wordpress/icons export in kebab-case and may be omitted. hasPricing (one field-level price) and hasOptions (a price per choice) are mutually exclusive.
add_filter( 'bespoke_product_options/field_types', function ( array $types ): array {
$types['engraving_font'] = array(
'type' => 'engraving_font',
'label' => 'Engraving Font',
'description' => 'Font picker for engraved text.',
'hasOptions' => false,
'hasPlaceholder' => false,
'hasPricing' => true,
'isContent' => false, // false = the field collects a value
);
return $types;
} );
The storefront renders unknown types through a dynamic action. Name the input bpo_addon[<field id>] (file inputs use bpo_addon_file_<field id>) and the value is parsed, validated and stored like a built-in field; a field-level price is applied as a flat fee.
add_action( 'bespoke_product_options/render_field_engraving_font', function ( array $field, $renderer ) {
printf(
'<select class="bpo-input" name="%s" id="%s"><option>Serif</option><option>Script</option></select>',
esc_attr( $renderer->getInputName( $field ) ),
esc_attr( $renderer->getInputId( $field ) )
);
}, 10, 2 );
Type-specific settings of a custom type are not stored by the builder; keep the configuration of such a field in code or in its label and price.
Render positions
↑ Back to topThe position setting maps a slug to the WooCommerce action the group renders on. Five positions ship by default: before_add_to_cart_button, before_add_to_cart_quantity, after_add_to_cart_quantity, after_add_to_cart_button and after_variations_table. Add your own for themes with non-standard product pages:
add_filter( 'bespoke_product_options/positions', function ( array $positions ): array {
$positions['my_theme_extras'] = 'mytheme_product_extras_hook';
return $positions;
} );
The action must fire inside the add to cart form, otherwise the submitted values never reach the cart. On variable products the first four built-in positions are relocated to after_variations_table, because WooCommerce hides that part of the form until a variation is chosen.
Storefront styles
↑ Back to topThe six bundled styles come from bespoke_product_options/themes. A registered style appears in the settings picker; the plugin ships stylesheets only for its own slugs, so enqueue your own, scoped to .bpo-theme-<slug> on the body.
add_filter( 'bespoke_product_options/themes', function ( array $themes ): array {
$themes['midnight'] = array(
'label' => 'Midnight',
'description' => 'Dark panel that matches our theme.',
);
return $themes;
} );
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_style( 'my-bpo-midnight', get_theme_file_uri( 'css/bpo-midnight.css' ), array( 'bespoke-product-options-base' ) );
} );
The plugin loads core.css always, then base.css and the style’s own file unless the style is None. If a stored slug disappears, the Default style is used.
Templates
↑ Back to topTemplates live under views/ inside the plugin: frontend/addon-group.php wraps a group, frontend/totals.php is the totals box, frontend/fields/field-wrapper.php renders the label, description and notices of every field, and frontend/fields/<type>.php renders the control (select, multiselect, buttons, pills, cards, images, colors, input, textarea, checkbox, quantity, dimensions, date, date-range, file, custom-price, heading).
Templates are resolved inside the plugin folder only; there is no theme override path. Two hooks cover customisation:
bespoke_product_options/field_template( string $template, array $field )swaps the control file of a field. The value is a file name insideviews/frontend/fields/. Return an empty string to take over rendering through therender_field_{type}action instead.bespoke_product_options/before_groupandafter_group( array $addon, WC_Product $product )fire inside the group wrapper, before and after the fields.
Every control template receives $field (the sanitised definition), $renderer (the FieldRenderer instance), $inputName and $inputId. The group template receives $addon, $product and $renderer; the totals template receives $product and $totalsLabel. Useful renderer methods: getValue(), getPlaceholder(), isOptionSelected(), getDisplayPrice(), getPriceHint(), getInputName(), getInputId().
Pricing and cart filters
↑ Back to topServer-side pricing is filterable at every step. The filtered value comes first.
bespoke_product_options/cart_entries( array $entries, array $addons, array $postData ): the normalised entries parsed from a submitted form, before they are stored on the cart item.bespoke_product_options/unit_price_adjustment( float $adjustment, array $entries, float $basePrice, int $quantity ): the per-unit amount added to the product price.bespoke_product_options/skip_cart_price_adjustment( bool $skip, string $cartKey, array $cartItem ): return true to leave a cart item’s price untouched, for plugins that compose the line price themselves.bespoke_product_options/entry_display_value( string $display, array $entry ): the human readable value shown in the cart, the checkout and the order screens.bespoke_product_options/reorder_entries( array $visible, array $storedEntries, array $addons ): the entries restored when a customer orders again.bespoke_product_options/edit_link( string $url, array $cartItem, string $cartItemKey ): the Edit choices URL on the cart line.
// Leave lines priced by another plugin alone.
add_filter( 'bespoke_product_options/skip_cart_price_adjustment', function ( $skip, $cartKey, $cartItem ) {
return ! empty( $cartItem['my_plugin_composed_price'] ) ? true : $skip;
}, 10, 3 );
The authoritative price is recalculated on woocommerce_before_calculate_totals at priority 9999; the browser math only drives the totals box. Anything that must survive to the cart belongs in these filters.
Currency integration
↑ Back to topSix currency switchers are supported out of the box. Anything else plugs into the same filter: convert a base-currency amount into the active currency and every option price, subtotal and stored cart value follows.
add_filter( 'bespoke_product_options/convert_price', function ( $price, $priceType ) {
return (float) $price * my_switcher_get_rate();
}, 10, 2 );
Percentage prices never pass through the filter, a ratio is currency neutral. The effective rate is probed with a large sample amount, so the filter must stay linear in the price. If your switcher converts prices when products are read (filters on woocommerce_product_get_price), call BespokeProductOptions\Services\CurrencyConverter::setConvertsOnRead( true ).
Targeting and admin filters
↑ Back to topbespoke_product_options/addons_for_product( array $matched, int $productId ): the final list of groups that apply to a product after status, assignment and visibility were checked, including the product’s own fields as a synthetic group. Cached per request.bespoke_product_options/supports_addons( bool $supported, WC_Product $product ): which product types render fields at all. Grouped and external products are excluded by default.bespoke_product_options/supports_order_editing( bool $supported, WC_Product $product ): which order line types get the Edit options button. Simple products and variations by default.bespoke_product_options/duplicate_data( array $data, array $addon ): the payload used when a group is duplicated.bespoke_product_options/suppress_admin_notices( bool $suppress ): return false to show other plugins’ admin notices on the Product Options screen.bespoke_product_options/integrations( array $integrations ): the integration registry; add an instance of a class extendingIntegrations\AbstractIntegrationto list it under Settings with its own switch.
// Hide a group from wholesale customers.
add_filter( 'bespoke_product_options/addons_for_product', function ( array $matched, int $productId ): array {
if ( current_user_can( 'wholesale_customer' ) ) {
$matched = array_values( array_filter( $matched, function ( $addon ) {
return 'gift-options' !== ( $addon['appearance']['cssClass'] ?? '' );
} ) );
}
return $matched;
}, 10, 2 );
File uploads
↑ Back to topbespoke_product_options/upload_subdir( string $subdir, array $uploadDir ): the protected subdirectory insidewp-content/uploadsthat customer files are stored in. Defaultbespoke_product_options_uploads/<hash of the customer id>. The filter must return the same value on every call, because it is also used to verify kept files.bespoke_product_options/upload_retention( int $seconds ): how long files never attached to an order are kept; derived from the retention setting.
Cleanup runs on the daily bpo_cleanup_uploads task, scheduled through Action Scheduler when WooCommerce provides it and through WP-Cron otherwise. The directory carries an index.php and an .htaccess that disables listings; on nginx add the equivalent rule yourself. Files attached to an order are always kept.
Assets and frontend data
↑ Back to topbespoke_product_options/enqueue_assets( bool $needed ): whether storefront assets load on the current page. Product, cart and checkout pages and the[product_page]shortcode are detected; return true for custom product templates.bespoke_product_options/script_dependencies( array $deps ): dependencies of the storefront script, by defaultjqueryandwp-i18n.bespoke_product_options/frontend_data( array $data ): thebpoAddonsDataobject localised to the storefront script. It carries the store’s currency format; add your own keys for a companion script.
JavaScript
↑ Back to topThe storefront script initialises every .bpo-addons group inside a form.cart and exposes jQuery events on that form:
jQuery( 'form.cart' )
// Fires before the totals are summed. The calculation object is mutable.
.on( 'spa:calculate', function ( e, calc ) {
calc.items.push( { label: 'Rush fee', price: 5, priceType: 'flat', total: 5 } );
} )
// Fires after every render with the final calculation object.
.on( 'spa:updated', function ( e, calc ) {
console.log( calc.subtotal );
} );
// Force a re-evaluation after changing the form programmatically.
jQuery( 'form.cart' ).trigger( 'spa:update' );
The calculation object is { basePrice, qty, items: [ { fieldId, label, price, priceType, total } ], productName, addonsTotal, subtotal }. addonsTotal and subtotal are set after spa:calculate, so listeners change items, basePrice or qty.
What the script reacts to on its own: change and input on the fields, change on the quantity input, and WooCommerce’s found_variation, reset_data and hide_variation events for the variation price. A custom variation UI must fire found_variation with variation_id and display_price, or set instance.basePrice and call instance.refresh(). For markup injected later, for example a quick view modal, call document.__spaAddons.init(); it is idempotent. Live instances are listed in document.__spaAddons.instances.
Markup hooks for styling and scripts: the group wrapper .bpo-addons carries data-group-id and data-product-id; every .bpo-field carries data-field-id, data-field-type, data-required, optional data-price and data-price-type, optional data-logic (JSON) and data-variations; every control has the .bpo-input class plus a modifier such as .bpo-input--text or .bpo-input--swatch; choice inputs carry data-price, data-price-type and data-label. Hidden fields get .bpo-field-hidden and their inputs are disabled, so they are never submitted. Invalid fields get .bpo-field-invalid.
spa:calculate changes the displayed totals only. The cart never trusts browser math; see the pricing filters above.
REST API
↑ Back to topThe admin app is an ordinary consumer of the plugin’s REST API, so everything the builder does, your tooling can do. All routes live under bespoke-product-options/v1 and use standard WordPress authentication (cookie plus X-WP-Nonce, or an application password). Every route requires the manage_woocommerce capability; the product routes additionally require edit rights on the product.
| Method | Route | Purpose |
|---|---|---|
| GET, POST | /addons | List option groups, create one |
| GET, PUT, DELETE | /addons/{id} | Read, update or delete a group |
| POST | /addons/{id}/duplicate | Duplicate a group; the copy arrives inactive |
| PUT | /addons/reorder | Persist a new group order ({"ids":[…]}) |
| POST | /addons/import | Import exported group JSON ({"groups":[…]}) |
| POST | /addons/bulk-delete | Delete several groups ({"ids":[…]}) |
| GET, PUT | /product-fields/{id} | Read or update a product’s own fields |
| GET | /product-addons | List products carrying product-specific fields |
| POST | /product-addons/export, /product-addons/import, /product-addons/bulk-delete | Export, import or clear product-specific fields |
| GET, POST | /option-sets | List choice sets, create one |
| PUT, DELETE | /option-sets/{id} | Update or delete a choice set |
| GET | /products, /variations/{id}, /terms, /customers, /attributes, /attribute-options | Search endpoints behind the builder’s pickers |
A group is a JSON object with title, status (publish or draft), fields, assignment, visibility, appearance and cart. On update, only the keys you send are written. The response adds id, fieldCount, createdAt and updatedAt.
curl -s -X POST 'https://example.com/wp-json/bespoke-product-options/v1/addons' \
-u 'admin:application-password' \
-H 'Content-Type: application/json' \
-d '{
"title": "Gift options",
"status": "publish",
"assignment": { "mode": "specific", "products": [ { "id": 123, "name": "Mug" } ] },
"appearance": { "position": "before_add_to_cart_button", "showTotals": true },
"fields": [ {
"type": "select", "label": "Gift wrap", "display": "pills",
"options": [
{ "label": "Plain", "price": 0, "priceType": "flat" },
{ "label": "Luxury", "price": 4.5, "priceType": "flat" }
]
} ]
}'
From inside wp-admin the nonce middleware is already registered:
wp.apiFetch( { path: '/bespoke-product-options/v1/addons' } ).then( console.log );
Store API
↑ Back to topCart items carry the selections in extensions["bespoke-product-options"].bpo_addon, a map of field id to display value, on GET /wc/store/v1/cart. To add an item with options through the Store API, send the values in the same place:
fetch( '/wp-json/wc/store/v1/cart/add-item', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Nonce': storeApiNonce },
body: JSON.stringify( {
id: 123,
quantity: 1,
extensions: { 'bespoke-product-options': { bpo_addon: {
'f1a2b3c4-…': 'opt-lux', // Multiple Choice: the choice id
'd4e5f6a7-…': 'Happy birthday', // text fields
'b8c9d0e1-…': [ 'opt-a', 'opt-b' ] // multi-select
} } }
} )
} );
Validation errors come back as a bespoke_product_options_invalid error with HTTP 400. File uploads cannot travel in a JSON body, so products with a File Upload field need the classic add to cart form.
Import and export format
↑ Back to topAn exported group file is a JSON array of groups, each carrying title, status, fields, assignment, visibility, appearance and cart, the same arrays the plugin stores as post meta. Imported groups arrive as drafts and every field and choice id is regenerated, with conditional logic rules and cart settings remapped, so nothing collides with existing groups. Product-specific exports carry { product: { id, name, sku }, fields, appearance, cart } per product and are matched by SKU first, then by product id, on import.
Shortcodes and blocks
↑ Back to topThe plugin registers no shortcode and no block. Fields render through the WooCommerce add to cart form actions listed under Render positions, which the Add to Cart Form block and classic templates both fire. Pages that embed a product with the [product_page] shortcode load the storefront assets automatically.
Extending the admin
↑ Back to top- New field types registered through
field_typesshow up in the builder with their label, description and price switch. - New positions and styles registered through
positionsandthemesshow up in the Appearance panel and the settings picker. - Integrations added through
integrationsget a switch under Settings → Integrations with a detected or idle status; implementgetSlug(),getTitle(),getDescription(),isAvailable()andrun()on a class extendingIntegrations\AbstractIntegration, orIntegrations\Currency\AbstractCurrencyIntegrationwithconvert( float $price )for a currency switcher. - The order screen editor uses the AJAX actions
bpo_order_item_formandbpo_order_item_savewith theedit_shop_orderscapability; the builder preview usesbpo_preview_addonwithmanage_woocommerce.
Debugging
↑ Back to topA group does not render. The gates run in this order: storefront assets are enqueued only on product, cart and checkout pages (enqueue_assets); grouped and external products are skipped (supports_addons); the group must be active; the product must match the assignment, compared against the parent for variations and against the default language under WPML or Polylang; the user must match the visibility rules, exclusions win; the group must have fields; and the group renders only on the WooCommerce action of its position, so a theme template that never fires that action shows nothing. apply_filters( 'bespoke_product_options/addons_for_product', array(), $productId ) is cached per request and shows what survived the matching steps.
The totals box is missing. It renders on woocommerce_before_add_to_cart_button at priority 20 when at least one applicable group has showTotals on, and the script hides it while no priced option is selected.
Prices differ between the page and the cart. The browser reads data-price attributes that the server already converted to the display currency and tax mode; the cart recalculates from the stored entries. A currency filter that is not linear, or a theme that replaces the quantity input without firing change, is the usual cause.
Uploads are rejected. The accepted types are the intersection of the field setting, the global setting and get_allowed_mime_types(); the size limit is the stricter of the field and the global value. Kept files on an edit must sit directly inside the customer’s own upload directory.
Minified assets. Release builds define BPO_PRODUCTION; when it is set the plugin serves .min.js and .min.css files where they exist. The plugin writes no log entries; failures surface as WP_Error objects, WooCommerce notices or REST errors with bpo_ codes.
Hooks reference
↑ Back to topAll hooks of version 1.0.1, grouped. Type A is an action, F a filter; the filtered value comes first in the parameters.
Rendering and assets
↑ Back to top| Hook | Type | Parameters | What it does |
|---|---|---|---|
positions | F | array $positions | Slug to WooCommerce action map of the render positions. |
themes | F | array $themes | Slug to label and description of the storefront styles. |
field_types | F | array $types | Field type definitions shown in the builder. |
field_template | F | string $template, array $field | Control template file of a field inside views/frontend/fields/. |
render_field_{type} | A | array $field, FieldRenderer $renderer | Renders a field type without a bundled template. |
before_group, after_group | A | array $addon, WC_Product $product | Inside the group wrapper, around the fields. |
supports_addons | F | bool $supported, WC_Product $product | Whether a product type renders fields. |
enqueue_assets | F | bool $needed | Whether storefront assets load on the current page. |
script_dependencies | F | array $deps | Dependencies of the storefront script. |
frontend_data | F | array $data | The bpoAddonsData object localised to the script. |
Pricing, cart and order
↑ Back to top| Hook | Type | Parameters | What it does |
|---|---|---|---|
cart_entries | F | array $entries, array $addons, array $postData | Entries parsed from a submitted form. |
unit_price_adjustment | F | float $adjustment, array $entries, float $basePrice, int $quantity | Per-unit amount added to the product price. |
skip_cart_price_adjustment | F | bool $skip, string $cartKey, array $cartItem | Leave a cart item’s price untouched. |
convert_price | F | float $price, string $priceType | Convert a base-currency amount into the active currency. |
entry_display_value | F | string $display, array $entry | Value shown in the cart, the checkout and the order. |
edit_link | F | string $url, array $cartItem, string $cartItemKey | The Edit choices URL on the cart line. |
reorder_entries | F | array $visible, array $storedEntries, array $addons | Entries restored on order again. |
Targeting, admin and uploads
↑ Back to top| Hook | Type | Parameters | What it does |
|---|---|---|---|
addons_for_product | F | array $matched, int $productId | Final list of groups for a product. |
supports_order_editing | F | bool $supported, WC_Product $product | Order line types with the Edit options button. |
duplicate_data | F | array $data, array $addon | Payload of a duplicated group. |
suppress_admin_notices | F | bool $suppress | Hide other notices on the Product Options screen. |
integrations | F | array $integrations | The integration registry. |
upload_subdir | F | string $subdir, array $uploadDir | Protected upload subdirectory. |
upload_retention | F | int $seconds | Retention of files not attached to an order. |
The source under src/ and views/ is the complete reference; every hook is called with its full name bespoke_product_options/<name>.
Support
↑ Back to top- User documentation: setup and every feature for store owners.
- Get help: missing a hook or an integration? Open a ticket from your WooCommerce.com account.