Bulk Price Editor – Developer Documentation

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 in src/. Every hook is prefixed with bulk_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 ProductQuery that is executed as a WC_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 top

Capability

↑ Back to top

The 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 top

Extend 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. Repeatable name[] fields arrive as arrays.
  • The two getters return the raw result. The base class applies the Rounding option in getFinalRegularPrice() and getFinalSalePrice(), 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 override renderAdvancedFields() to add your own fields.
  • Override updatePrices( \WC_Product $product ): void to write something other than prices, as the Tiered Pricing modifier does. Fire do_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 an init callback. The array order is the tab order.

Product filters

↑ Back to top

Extend 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 top

Extend 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 top

Add 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 top

An 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 top

renderFields() 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 top

The 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 top

The 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 top

The 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 top

wp 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.

InputExample
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_price49.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 top

All endpoints require the plugin capability and a nonce, except the price history modal, which requires edit_post on the product.

ActionTypePurpose
bpe_load_products_tablewp_ajaxRenders the preview table from the posted filters and method.
bulk_price_editor_schedule_price_updatingwp_ajaxStarts an update now or stores a scheduled run (schedule JSON: mode, schedule_at, revert_at).
bulk_price_editor_get_price_updating_progresswp_ajaxReturns running, total, processed and type for the progress screen, polled every 3 seconds.
bulk_price_editor_stop_price_updatingadmin_postCancels the pending chunk jobs and marks the current run as stopped.
bulk_price_editor_start_revertadmin_postStarts a revert run for a finished run (run_id).
bulk_price_editor_cancel_scheduled_run, bulk_price_editor_delete_runadmin_postCancels a scheduled run with its automatic revert; deletes a finished run and its snapshot.
bulk_price_editor_export_preview, bulk_price_editor_export_runadmin_postCSV of the current preview; CSV of a run’s snapshot.
bulk_price_editor_save_settingsadmin_postSaves the Settings tab.
bpe_price_historywp_ajaxReturns the rendered price log of one product (product_id).
bulk_price_editor_add_template, bulk_price_editor_update_templatewp_ajaxCreates or updates a template; an optional priceModifiers JSON stores the method with it.
bulk_price_editor_remove_template, bulk_price_editor_select_templateadmin_postDeletes or activates a template.
woocommerce_json_search_bpe_categories, ..._bpe_brands, ..._bpe_tags, ..._bpe_shipping_classes, ..._bpe_attributewp_ajaxSelect2 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 => -1 and return => '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_update and 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 processed counter of the run row, incremented after each chunk. The option bpe_current_run_id points 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
KeyTypeContents
{prefix}bpe_runstableOne 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_pricestableSnapshot 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_logtableThe 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_versionoptionSchema version. Tables are created or upgraded with dbDelta on activation and on init when the version changes.
bpe_current_run_idoptionId of the run in progress; absent when idle.
bulk_price_editor_templatesoptionAll templates: slug => {name, filters, data}; data.modifier holds the saved price method of a job.
bulk_price_editor_price_notifications_{user_id}optionQueued notifications, per user.
bpe_price_log_enabled, bpe_price_log_max_per_productoptionsThe Settings tab values.
bpe_plugin_activation_timestampoptionFirst 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 requires edit_post for the product.
  • Every write endpoint is nonce-protected; the Select2 lookups reuse the WooCommerce search-products nonce.
  • Inputs are sanitized with sanitize_text_field, wc_format_decimal and id whitelists; the product name search uses $wpdb->prepare with esc_like, and the term clauses interpolate integer ids only.
  • Formulas are evaluated by the plugin’s own parser in PriceModifiers\PriceFormula, never with eval().

Debugging

↑ Back to top
  • In the browser console, document.bulkPriceEditor.filters.getAll() returns the collected filter values and document.bulkPriceEditor.priceAdjustments.getSelected() the selected method with its arguments.
  • The preview request is the admin-ajax.php call with the action bpe_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 history lists the same runs as the History tab; wp bulk-price-editor run --dry-run prints 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 false from bulk_price_editor/suppress_admin_notices to see them.

Hooks reference

↑ Back to top

The 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
HookPayloadPurpose
bulk_price_editor/capabilitystring (default manage_options)Capability for the editor page and all of its actions.
bulk_price_editor/price_modifiersstring[] class namesRegisters price methods; the array order is the tab order. Runs during init.
bulk_price_editor/query/filtersstring[] class namesRegisters filters for query building. Each class is instantiated with ($rawFiltersData, $template).
bulk_price_editor/editor_page/filtersFilter[] instancesRegisters the filter sections on the editor page.
bulk_price_editor/integrationsstring[] class namesRegisters integrations; classes must extend Integration.
bulk_price_editor/container/service_instancestring class nameSwaps a class instantiated by the service container.

Query and preview

↑ Back to top
HookPayloadPurpose
bulk_price_editor/products_table/columnsColumn[], WPListProductsTable $tableAdds, removes or reorders the preview columns.
bulk_price_editor/supported_product_typeslug => label mapThe product types the Product Type filter offers.
bulk_price_editor/default_product_typesstring[] slugs (default simple, variation, external)The types checked by default.
bulk_price_editor/suppress_admin_noticesbool (default true)Whether other admin notices are hidden on the editor page.

Price calculation

↑ Back to top
HookPayloadPurpose
bulk_price_editor/formula/variablesarray $variables, WC_Product $productVariables available in Update by formula.
bulk_price_editor/product/before_price_update (action)WC_Product $product, PriceModifier $modifierFires 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
HookPayloadPurpose
bulk_price_editor/action/update_prices/products_per_runint (default 100)Products per background job. The older misspelled name products_pee_run still applies first.
bulk_price_editor/history/max_runsint (default 50)Finished runs kept in History; scheduled and running runs are never pruned.
bulk_price_editor/price_log/enabledboolForces the price log on or off regardless of the Settings tab.
bulk_price_editor/price_log/max_entries_per_productint (default from Settings, 100)Changes kept per product.

Templates

↑ Back to top
HookPayloadPurpose
bulk_price_editor/template/locationstring $file, string $templatePath resolved by the internal template loader.
bulk_price_editor/template/before_render (action)$template, $variablesBefore an internal template include.
bulk_price_editor/template/after_render (action)$template, $variablesAfter an internal template include.

Action Scheduler jobs

↑ Back to top
HookPayloadPurpose
bulk_price_editor_update_pricesint $runId, int[] $productIdsProcesses one chunk of an update run.
bulk_price_editor_revert_pricesint $revertRunId, int[] $snapshotRowIdsRestores one chunk of a revert run.
bulk_price_editor_start_scheduled_runint $runIdStarts a scheduled update at its time, after resolving the filters again.
bulk_price_editor_start_scheduled_revertint $runIdPerforms an automatic revert at its time.

Support

↑ Back to top

For questions about the plugin, open a ticket through WooCommerce.com support. The user documentation covers the features from the store owner’s side.

Use of your personal data
We and our partners process your personal data (such as browsing data, IP Addresses, cookie information, and other unique identifiers) based on your consent and/or our legitimate interest to optimize our website, marketing activities, and your user experience.