This document is for developers who extend Bulk Price Editor for WooCommerce with code: custom price methods, filters, table columns and integrations, the command line, the data tables, debugging and the list of hooks. Store owners find the plugin’s features in the user documentation.
Overview
↑ Back to top- Classes are autoloaded (PSR-4) under the
BulkPriceEditor\namespace insrc/. Every hook is prefixed withbulk_price_editor/. - Price methods (“modifiers”) are registered in
PriceModifiers\Dispatcher. Each modifier renders its own form fields and computes the new prices. The preview and the update call the same methods, so the preview is exact. - Product filters add arguments to a shared
ProductQuerythat is executed as aWC_Product_Query. Term-based filters add a variation-aware WHERE clause so variations are matched through their parent. - All price writes go through the WooCommerce CRUD (
set_regular_price(),set_sale_price(),save()), which keeps_price, caches and lookup tables in sync. - Every bulk update is a run (
History\Run) in a custom table with a per-product snapshot of the prices before the change. The snapshot is the basis for revert and automatic revert. - Updates run through the Action Scheduler in chunks of 100 products in the queue group
bulk-price-editor__prices. - A price log records every regular or sale price change from any source at the post meta level.
Hooks
↑ Back to topCapability
↑ Back to topThe editor page and all of its actions require manage_options. Return another capability to open the editor to other roles:
add_filter( 'bulk_price_editor/capability', function () {
return 'manage_woocommerce';
} );
Price methods
↑ Back to topExtend BulkPriceEditor\PriceModifiers\Modifiers\PriceModifier, implement the abstract methods and register the class:
use BulkPriceEditor\PriceModifiers\Modifiers\PriceModifier;
use BulkPriceEditor\PriceEditorPage\Widgets\Widget;
class DoublePricesModifier extends PriceModifier {
public function getType(): string {
return 'double_prices'; // unique slug, also the tab id
}
public function getName() {
return __( 'Double prices', 'my-plugin' );
}
public function getUpdatedRegularPrice( \WC_Product $product ) {
$price = $product->get_regular_price( 'edit' );
// null skips the product: no change, no preview arrow.
return is_numeric( $price ) ? (float) $price * 2 : null;
}
public function getUpdatedSalePrice( \WC_Product $product ) {
$price = $product->get_sale_price( 'edit' );
return is_numeric( $price ) ? (float) $price * 2 : null;
}
public function renderFields( Widget $widget ) {
$widget->renderHint( __( 'Doubles the prices of the selected products.', 'my-plugin' ) );
// Every input needs data-price-modificator="yes" to be collected.
$widget->renderTextInput( array(
'id' => 'my_custom_arg',
'label' => __( 'Example argument', 'my-plugin' ),
'custom_attributes' => array( 'data-price-modificator' => 'yes' ),
) );
}
}
add_filter( 'bulk_price_editor/price_modifiers', function ( $modifiers ) {
$modifiers[] = DoublePricesModifier::class;
return $modifiers;
} );
- Submitted values are available through
$this->getRawArgs(), keyed by the input name. Repeatablename[]fields arrive as arrays. - The two getters return the raw result. The base class applies the Rounding option in
getFinalRegularPrice()andgetFinalSalePrice(), which the preview, the update and the snapshot use. - The Advanced Options panel (Rounding and Sale dates) is on by default. Set
protected $supports = array( 'advanced_options' => false );to remove it, or overriderenderAdvancedFields()to add your own fields. - Override
updatePrices( \WC_Product $product ): voidto write something other than prices, as the Tiered Pricing modifier does. Firedo_action( 'bulk_price_editor/product/before_price_update', $product, $this )before saving so the run keeps its snapshot. - Register before or during
init; the registry is built in aninitcallback. The array order is the tab order.
Product filters
↑ Back to topExtend BulkPriceEditor\ProductFilters\Filters\Filter and register it on two hooks: one builds the query, the other renders the section on the editor page.
use BulkPriceEditor\ProductFilters\Filters\Filter;
use BulkPriceEditor\ProductQuery\ProductQuery;
use BulkPriceEditor\PriceEditorPage\Widgets\Widget;
class MinimumStockFilter extends Filter {
public function getTitle(): string {
return __( 'Minimum stock', 'my-plugin' );
}
public function filterQuery( ProductQuery $query ): void {
// Reads the submitted value, or the active template's value.
$minimum = (int) $this->getFilterValue( 'my_minimum_stock' );
if ( $minimum > 0 ) {
$query->args['meta_query'][] = array(
'key' => '_stock',
'value' => $minimum,
'compare' => '>=',
'type' => 'NUMERIC',
);
}
}
public function renderFields( Widget $widget ) {
// Every input needs data-product-filter="yes": its value is
// collected and a change refreshes the preview table.
$widget->renderTextInput( array(
'id' => 'my_minimum_stock',
'type' => 'number',
'label' => __( 'Stock at least', 'my-plugin' ),
'custom_attributes' => array( 'data-product-filter' => 'yes' ),
) );
}
}
// Query building: class names.
add_filter( 'bulk_price_editor/query/filters', function ( $filters ) {
$filters[] = MinimumStockFilter::class;
return $filters;
} );
// Editor page: instances.
add_filter( 'bulk_price_editor/editor_page/filters', function ( $filters ) {
$filters[] = new MinimumStockFilter( array() );
return $filters;
} );
$query->args is passed to WC_Product_Query, so any of its arguments can be set. Filters run in registration order and are combined with AND. Two details: an empty include is dropped by WC_Product_Query and matches everything, so return array( 0 ) for no match; and to match variations through their parent, push a clause onto TermsFilter::QUERY_VAR and call TermsFilter::registerQueryFilter(), as the built-in term filters do.
Preview table columns
↑ Back to topExtend BulkPriceEditor\ProductsTable\Columns\Column:
use BulkPriceEditor\ProductsTable\Columns\Column;
use BulkPriceEditor\ProductsTable\WPListProductsTable;
class MarginColumn extends Column {
public function getSlug(): string {
return 'margin';
}
public function getName(): string {
return __( 'Margin', 'my-plugin' );
}
public function sortable(): bool {
return false;
}
public function render( \WC_Product $product, WPListProductsTable $table ): string {
// $table->priceModifier is the active method (null on the first load).
$cost = (float) $product->get_meta( '_my_cost' );
$new = $table->priceModifier ? $table->priceModifier->getFinalRegularPrice( $product ) : null;
$price = null !== $new ? $new : (float) $product->get_regular_price();
return $price ? wc_price( $price - $cost ) : 'N/A';
}
}
add_filter( 'bulk_price_editor/products_table/columns', function ( $columns, $table ) {
$columns[] = new MarginColumn();
return $columns;
}, 10, 2 );
The orderby list used for sorting is fixed, so a sortable custom column also needs its own query adjustment through bulk_price_editor/query/filters.
Formula variables
↑ Back to topAdd variables to the Update by formula method:
add_filter( 'bulk_price_editor/formula/variables', function ( $variables, $product ) {
$variables['msrp'] = (float) $product->get_meta( '_msrp' );
return $variables;
}, 10, 2 );
{msrp} is then available in formulas. A product whose variable resolves to an empty value is skipped for that field.
Integrations
↑ Back to topAn integration bundles modifiers, columns and assets behind a check for a third-party plugin. Extend BulkPriceEditor\Integrations\Integration with getName(), getDescription(), getSlug() and run(), and register the class on bulk_price_editor/integrations. Classes that do not extend Integration are dropped. The bundled Tiered Pricing Table integration in src/Integrations/TieredPricing/ is the reference: it registers a modifier, adds a preview column and enqueues its stylesheet on the editor page only.
Form controls
↑ Back to toprenderFields() receives a Widget with styled controls: renderHint( $text ), renderTextInput( $args ) (supports type, placeholder, description, desc_tip and custom_attributes), renderSelect( $args ), renderSelect2( $args ) (AJAX search with search_action, minimum_input_length and multiple), renderRadioButtons( $args ), renderCheckboxGroup( $args ) and renderCheckbox( $args ). Add the WooCommerce class wc_input_price to plain money fields for currency formatting, but not to formula fields, where it strips the operators.
Templates
↑ Back to topThe admin screens are rendered by PHP classes (widgets, columns and modals), not by template files, so there is nothing to override from a theme. The internal file manager has a template loader with the filter bulk_price_editor/template/location and the actions bulk_price_editor/template/before_render and bulk_price_editor/template/after_render, but the current screens do not use it. Extend the editor through the hooks above instead.
JavaScript
↑ Back to topThe editor page script is loaded as bulk-price-editor-page-js and exposes its orchestrator as document.bulkPriceEditor with the members filters, priceAdjustments, productTable, applyChanges, updateProgress and snackbar. Inputs are collected by data attributes: data-product-filter="yes" for filter fields (a change refreshes the table) and data-price-modificator="yes" for method fields. Strings and URLs reach the script through wp_localize_script as bulkPriceEditorData; the product screen script bpe-price-history receives bpePriceHistory with ajaxUrl, action and nonce. The plugin does not emit custom DOM events.
REST API and shortcodes
↑ Back to topThe plugin is an administration tool: it adds no REST API endpoints and no shortcodes. Price changes made through the WooCommerce REST API are recorded in the price log with the source “REST API”. For automation, use WP-CLI or the PHP classes.
WP-CLI
↑ Back to topwp bulk-price-editor exposes the same operations as the editor: templates, run, history [--limit=<n>] and revert <run-id> [--yes]. run takes --template=<slug-or-name>, or --filters=<json>, --modifier=<type> and --args=<json>, plus --dry-run and --yes. Runs started from the command line are processed synchronously in chunks, recorded in History with snapshots and attributed to WP-CLI in the price log.
wp bulk-price-editor run --filters='{"bpe_included_category_ids":[15]}' \
--modifier=decrease_by_percent --args='{"bpe_regular_price_decrease_by_percent":10}' --dry-run
Filter keys and method arguments are the input names of the editor. Method types: new_prices, remove_prices, increase_by_percent, decrease_by_percent, add_flat_value, subtract_flat_value, update_by_formula and, with the integration, tiered_pricing.
| Input | Example |
|---|---|
bpe_included_category_ids, bpe_included_brand_ids, bpe_included_tag_ids, bpe_included_product_ids, bpe_included_shipping_class_ids | [15, 16] |
bpe_excluded_* (same keys) | [42] |
bpe_stock_status, bpe_on_sale_status, bpe_featured_status | "instock", "on_sale", "featured" |
bpe_price_range_type, bpe_price_range_min, bpe_price_range_max | "regular", 10, 20 |
bpe_date_range_type, bpe_date_range_from, bpe_date_range_to | "modified", "2026-01-01", "2026-03-31" |
bpe_product_type | ["simple", "variation"] |
bpe_sku_contains, bpe_title_like | "HP-HD", "Hoodie" |
bpe_product_attribute_pa_color | [22, 23] (term ids) |
bpe_regular_price_decrease_by_percent, bpe_sale_price_decrease_by_percent (and the increase, add_flat_value, subtract_flat_value variants) | 10 |
bpe_new_regular_price, bpe_new_sale_price | 49.99 |
bpe_formula_regular_price, bpe_formula_sale_price | "{cost} * 1.4" |
bpe_remove_prices | "both", "regular_price", "sale_price" |
bpe_price_rounding | "99" |
bpe_sale_dates_action, bpe_sale_date_from, bpe_sale_date_to | "set", "2027-06-01", "2027-08-31" |
AJAX endpoints
↑ Back to topAll endpoints require the plugin capability and a nonce, except the price history modal, which requires edit_post on the product.
| Action | Type | Purpose |
|---|---|---|
bpe_load_products_table | wp_ajax | Renders the preview table from the posted filters and method. |
bulk_price_editor_schedule_price_updating | wp_ajax | Starts an update now or stores a scheduled run (schedule JSON: mode, schedule_at, revert_at). |
bulk_price_editor_get_price_updating_progress | wp_ajax | Returns running, total, processed and type for the progress screen, polled every 3 seconds. |
bulk_price_editor_stop_price_updating | admin_post | Cancels the pending chunk jobs and marks the current run as stopped. |
bulk_price_editor_start_revert | admin_post | Starts a revert run for a finished run (run_id). |
bulk_price_editor_cancel_scheduled_run, bulk_price_editor_delete_run | admin_post | Cancels a scheduled run with its automatic revert; deletes a finished run and its snapshot. |
bulk_price_editor_export_preview, bulk_price_editor_export_run | admin_post | CSV of the current preview; CSV of a run’s snapshot. |
bulk_price_editor_save_settings | admin_post | Saves the Settings tab. |
bpe_price_history | wp_ajax | Returns the rendered price log of one product (product_id). |
bulk_price_editor_add_template, bulk_price_editor_update_template | wp_ajax | Creates or updates a template; an optional priceModifiers JSON stores the method with it. |
bulk_price_editor_remove_template, bulk_price_editor_select_template | admin_post | Deletes or activates a template. |
woocommerce_json_search_bpe_categories, ..._bpe_brands, ..._bpe_tags, ..._bpe_shipping_classes, ..._bpe_attribute | wp_ajax | Select2 lookups for the term filters. |
Background processing
↑ Back to top- On apply, a run row is created with the filters and the method settings. The filter query runs again with
limit => -1andreturn => 'ids', so the update targets the full filtered set at that moment (for a scheduled run, at the moment it starts). Variations whose parent is in the list are dropped, because updating the parent updates them. - The ids are chunked, 100 per chunk by default. The first chunk is processed synchronously for immediate feedback; the rest are queued with
WC()->queue()->add( 'bulk_price_editor_update_prices', array( $runId, $ids ), 'bulk-price-editor__prices' ). The ids travel as Action Scheduler arguments, which needs Action Scheduler 3.5 (WooCommerce 7.1) or newer. - Before each product is saved, the modifier fires
bulk_price_editor/product/before_price_updateand the snapshot row (old and new regular price, sale price and sale dates) is written. A retried chunk restores the snapshot values in memory before applying the method again, so a percentage change is never applied twice. - Progress is the
processedcounter of the run row, incremented after each chunk. The optionbpe_current_run_idpoints at the run in progress. - A revert is a run of type
revert. Its chunks carry the snapshot row ids of the parent run and restore only the fields the parent changed; each restore is snapshotted again, so a revert can be reverted. - Stopping cancels the pending jobs through
WC()->queue()->cancel_all()and marks the run as stopped. Processed products keep their snapshot and can still be reverted.
Data storage
↑ Back to top| Key | Type | Contents |
|---|---|---|
{prefix}bpe_runs | table | One row per update or revert run: type, status, method type and arguments, filters, totals, user and timestamps (created, scheduled, revert, started, completed, reverted). |
{prefix}bpe_run_prices | table | Snapshot rows of a run: old and new regular price, sale price and sale dates per product. A NULL new value means the field was not touched; an empty string means it was cleared. |
{prefix}bpe_price_log | table | The price change log: product, timestamp, old and new value of the changed field, source (run with the run id, admin, api, cli, cron, ajax, other) and user. |
bpe_db_version | option | Schema version. Tables are created or upgraded with dbDelta on activation and on init when the version changes. |
bpe_current_run_id | option | Id of the run in progress; absent when idle. |
bulk_price_editor_templates | option | All templates: slug => {name, filters, data}; data.modifier holds the saved price method of a job. |
bulk_price_editor_price_notifications_{user_id} | option | Queued notifications, per user. |
bpe_price_log_enabled, bpe_price_log_max_per_product | options | The Settings tab values. |
bpe_plugin_activation_timestamp | option | First activation time. |
Nothing is removed on uninstall, so templates, history and the log survive a reinstall.
Security
↑ Back to top- The editor page and every price-changing endpoint require the capability returned by
bulk_price_editor/capability. The price history modal requiresedit_postfor the product. - Every write endpoint is nonce-protected; the Select2 lookups reuse the WooCommerce
search-productsnonce. - Inputs are sanitized with
sanitize_text_field,wc_format_decimaland id whitelists; the product name search uses$wpdb->preparewithesc_like, and the term clauses interpolate integer ids only. - Formulas are evaluated by the plugin’s own parser in
PriceModifiers\PriceFormula, never witheval().
Debugging
↑ Back to top- In the browser console,
document.bulkPriceEditor.filters.getAll()returns the collected filter values anddocument.bulkPriceEditor.priceAdjustments.getSelected()the selected method with its arguments. - The preview request is the
admin-ajax.phpcall with the actionbpe_load_products_table; its response is the rendered table. - Pending and failed jobs are under WooCommerce → Status → Scheduled Actions in the group
bulk-price-editor__prices. Scheduled updates and automatic reverts are single actions in the same group. wp bulk-price-editor historylists the same runs as the History tab;wp bulk-price-editor run --dry-runprints what a job would do without changing anything.- The Price history link on a product shows which source changed a price and when, including changes made outside the plugin.
- Other plugins’ admin notices are hidden on the editor page; return
falsefrombulk_price_editor/suppress_admin_noticesto see them.
Hooks reference
↑ Back to topThe plugin defines 20 hooks; all of them are listed here. The payload column gives the filtered value and the extra arguments.
Access and registration
↑ Back to top| Hook | Payload | Purpose |
|---|---|---|
bulk_price_editor/capability | string (default manage_options) | Capability for the editor page and all of its actions. |
bulk_price_editor/price_modifiers | string[] class names | Registers price methods; the array order is the tab order. Runs during init. |
bulk_price_editor/query/filters | string[] class names | Registers filters for query building. Each class is instantiated with ($rawFiltersData, $template). |
bulk_price_editor/editor_page/filters | Filter[] instances | Registers the filter sections on the editor page. |
bulk_price_editor/integrations | string[] class names | Registers integrations; classes must extend Integration. |
bulk_price_editor/container/service_instance | string class name | Swaps a class instantiated by the service container. |
Query and preview
↑ Back to top| Hook | Payload | Purpose |
|---|---|---|
bulk_price_editor/products_table/columns | Column[], WPListProductsTable $table | Adds, removes or reorders the preview columns. |
bulk_price_editor/supported_product_type | slug => label map | The product types the Product Type filter offers. |
bulk_price_editor/default_product_types | string[] slugs (default simple, variation, external) | The types checked by default. |
bulk_price_editor/suppress_admin_notices | bool (default true) | Whether other admin notices are hidden on the editor page. |
Price calculation
↑ Back to top| Hook | Payload | Purpose |
|---|---|---|
bulk_price_editor/formula/variables | array $variables, WC_Product $product | Variables available in Update by formula. |
bulk_price_editor/product/before_price_update (action) | WC_Product $product, PriceModifier $modifier | Fires for every concrete product, variations included, right before a method saves its new prices. The snapshot is recorded here. |
Background processing and history
↑ Back to top| Hook | Payload | Purpose |
|---|---|---|
bulk_price_editor/action/update_prices/products_per_run | int (default 100) | Products per background job. The older misspelled name products_pee_run still applies first. |
bulk_price_editor/history/max_runs | int (default 50) | Finished runs kept in History; scheduled and running runs are never pruned. |
bulk_price_editor/price_log/enabled | bool | Forces the price log on or off regardless of the Settings tab. |
bulk_price_editor/price_log/max_entries_per_product | int (default from Settings, 100) | Changes kept per product. |
Templates
↑ Back to top| Hook | Payload | Purpose |
|---|---|---|
bulk_price_editor/template/location | string $file, string $template | Path resolved by the internal template loader. |
bulk_price_editor/template/before_render (action) | $template, $variables | Before an internal template include. |
bulk_price_editor/template/after_render (action) | $template, $variables | After an internal template include. |
Action Scheduler jobs
↑ Back to top| Hook | Payload | Purpose |
|---|---|---|
bulk_price_editor_update_prices | int $runId, int[] $productIds | Processes one chunk of an update run. |
bulk_price_editor_revert_prices | int $revertRunId, int[] $snapshotRowIds | Restores one chunk of a revert run. |
bulk_price_editor_start_scheduled_run | int $runId | Starts a scheduled update at its time, after resolving the filters again. |
bulk_price_editor_start_scheduled_revert | int $runId | Performs an automatic revert at its time. |
Support
↑ Back to topFor questions about the plugin, open a ticket through WooCommerce.com support. The user documentation covers the features from the store owner’s side.