Overview
Bulk Price Editor is built to be extended. Custom price modifiers, product filters, table columns, and third-party integrations can all be registered through WordPress filters, without touching the plugin’s code.
Architecture at a glance
- PSR-4 autoloaded classes under the
BulkPriceEditor\namespace (src/directory). - A lightweight service container (
BulkPriceEditor\Core\ServiceContainer) provides shared services; concrete classes can be swapped via a filter. - Price modifiers are dispatched by
PriceModifiers\Dispatcher; each modifier both renders its own form fields and computes the new prices. - Product filters mutate a shared
ProductQuerythat is executed as a standardWC_Product_Query. Term-based filters (categories, brands, tags, shipping classes) add a variation-awareposts_whereclause so variations are matched through their parent. - All price writes go through the WooCommerce CRUD (
set_regular_price(),set_sale_price(),save()), keeping_price, caches, and lookup tables in sync. - Every bulk update is a run (
History\Run) stored in a custom table together with a per-product snapshot of the prices before the change — the basis for revert, redo and automatic revert. - Bulk updates run through Action Scheduler in chunks of 100 products (queue group
bulk-price-editor__prices); chunk ids travel as action arguments. - A price log records every regular/sale price change from any source at the postmeta level.
The same modifier methods that compute the live preview also perform the final update — preview and result are always identical.
Hooks Reference
All plugin hooks are prefixed with bulk_price_editor/.
Filters
| Hook | Payload | Purpose |
|---|---|---|
bulk_price_editor/capability |
string (default manage_options) |
Capability required for the editor page and all of its actions. Return manage_woocommerce to open the editor to shop managers. |
bulk_price_editor/price_modifiers |
string[] class names |
Register custom price modifiers. Array order controls tab order. Runs during init. |
bulk_price_editor/query/filters |
string[] class names |
Register a custom filter for query building. Each class is instantiated with ($rawFiltersData, $template). |
bulk_price_editor/editor_page/filters |
Filter[] instances |
Register the same filter’s UI section on the editor page. Note: this hook receives instances, not class names. |
bulk_price_editor/products_table/columns |
(Column[] $columns, WPListProductsTable $table) |
Add, remove or reorder preview-table columns. |
bulk_price_editor/integrations |
string[] class names |
Register an integration class (must extend Integration; other classes are silently dropped). |
bulk_price_editor/formula/variables |
(array $variables, WC_Product $product) |
Add variables to the Update by formula modifier, e.g. {msrp} from your own meta. |
bulk_price_editor/supported_product_type |
slug => label map |
Adjust the product types the plugin can update. |
bulk_price_editor/default_product_types |
string[] slugs |
The product types checked by default in the Product Type filter. (Before 3.0 this shared the hook name above with a different payload.) |
bulk_price_editor/action/update_prices/products_per_run |
int (default 100) |
Chunk size per background job. The previous, misspelled name …/products_pee_run still applies first for backwards compatibility. |
bulk_price_editor/history/max_runs |
int (default 50) |
How many finished runs (and their snapshots) to keep in the history. Scheduled and running runs are never pruned. |
bulk_price_editor/price_log/enabled |
bool |
Force the price change log on or off regardless of the Settings tab. |
bulk_price_editor/price_log/max_entries_per_product |
int (default from Settings, 100) |
How many price changes to keep per product. |
bulk_price_editor/suppress_admin_notices |
bool (default true) |
Whether foreign admin notices are hidden on the editor page. |
bulk_price_editor/container/service_instance |
string class name |
Swap a concrete class instantiated by the service container (DI override point). |
bulk_price_editor/template/location |
(string $file, string $template) |
Override a template file path resolved by the internal file manager. |
Actions
| Hook | Payload | Fires |
|---|---|---|
bulk_price_editor/product/before_price_update |
(WC_Product $product, PriceModifier $modifier) |
For every concrete product (variations included) right before a modifier saves its new prices. The history snapshot is recorded on this hook; use it for your own auditing or to block a change by throwing. |
bulk_price_editor/template/before_render |
($template, $variables) |
Immediately before an internal template include. |
bulk_price_editor/template/after_render |
($template, $variables) |
Immediately after an internal template include. |
bulk_price_editor_update_prices |
(int $runId, int[] $productIds) |
Action Scheduler job processing one chunk of an update run. |
bulk_price_editor_revert_prices |
(int $revertRunId, int[] $snapshotRowIds) |
Action Scheduler job restoring one chunk of a revert run. |
bulk_price_editor_start_scheduled_run |
(int $runId) |
Action Scheduler job that starts a scheduled update at its time (re-resolves the filters first). |
bulk_price_editor_start_scheduled_revert |
(int $runId) |
Action Scheduler job that performs an automatic revert at its time. |
Adding a Custom Price Modifier
Extend BulkPriceEditor\PriceModifiers\Modifiers\PriceModifier and implement its abstract methods:
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's DOM id
}
public function getName() {
return __( 'Double prices', 'my-plugin' );
}
public function getUpdatedRegularPrice( \WC_Product $product ) {
$price = $product->get_regular_price( 'edit' );
// Return null to skip this 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 regular and sale prices of the selected products.', 'my-plugin' ) );
// Every input MUST carry data-price-modificator="yes"
// or the JS will not collect its value.
$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;
} );
Useful details:
- Submitted field values are available via
$this->getRawArgs(), keyed by the input’snameattribute (repeatablename[]fields arrive as arrays). - Your two getters return the raw result; the base class applies the shared Rounding option on top (
getFinalRegularPrice()/getFinalSalePrice()), which is what the preview, the update and the snapshot all use. - The collapsible Advanced Options panel (Rounding and Sale dates) is on by default. Set
protected $supports = array( 'advanced_options' => false );to drop it, or overriderenderAdvancedFields()to add your own fields. - Override
updatePrices( \WC_Product $product ): voidif your modifier writes something other than regular/sale prices (this is how the Tiered Pricing modifier works). The default implementation applies your two getters and the sale dates, and recurses into variations for variable products. If you override it, firedo_action( 'bulk_price_editor/product/before_price_update', $product, $this )before saving so the run keeps its snapshot and stays revertible. - Register before or during
init— the modifier registry is built in aninitcallback.
Adding a Custom Product Filter
Extend BulkPriceEditor\ProductFilters\Filters\Filter, then register it on both hooks — one drives the query, the other renders the UI section:
use BulkPriceEditor\ProductFilters\Filters\Filter;
use BulkPriceEditor\ProductQuery\ProductQuery;
use BulkPriceEditor\PriceEditorPage\Widgets\Widget;
class OnSaleFilter extends Filter {
public function getTitle(): string {
return __( 'On Sale', 'my-plugin' );
}
public function filterQuery( ProductQuery $query ): void {
// Read the submitted value (falls back to the active template).
if ( $this->getFilterValue( 'my_on_sale_only' ) ) {
$query->args['include'] = wc_get_product_ids_on_sale();
}
}
public function renderFields( Widget $widget ) {
// Every input MUST carry data-product-filter="yes" so its value
// is collected and changes trigger a table refresh.
$widget->renderCheckbox( array(
'id' => 'my_on_sale_only',
'label' => __( 'Only products on sale', 'my-plugin' ),
'custom_attributes' => array( 'data-product-filter' => 'yes' ),
) );
}
}
// 1) Query building: class names.
add_filter( 'bulk_price_editor/query/filters', function ( $filters ) {
$filters[] = OnSaleFilter::class;
return $filters;
} );
// 2) Editor page UI: instances.
add_filter( 'bulk_price_editor/editor_page/filters', function ( $filters ) {
$filters[] = new OnSaleFilter( array() );
return $filters;
} );
$query->args is a plain array that is later passed to WC_Product_Query — you can set any argument it supports (tax_query, include/exclude, meta-based args, and so on). Filters run in registration order and are combined with AND semantics. Two things to know: an empty include is dropped by WC_Product_Query and would match everything — return array( 0 ) for “no match” instead; 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.
Adding a Custom Table Column
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 modifier (may be null
// on the initial page load) - use it for old-to-new previews.
$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 );
Sortable third-party columns are limited: the
orderbywhitelist inWPListProductsTable::addSorting()is hard-coded, so a custom sortable column also needs its own query adjustment (e.g. viabulk_price_editor/query/filters).
Building an Integration
Integrations bundle modifiers, columns, and assets behind a third-party-plugin check. Extend BulkPriceEditor\Integrations\Integration:
use BulkPriceEditor\Integrations\Integration;
class MyPluginIntegration extends Integration {
public function getName(): string {
return 'My Plugin';
}
public function getDescription(): string {
return 'Integrates with My Plugin.';
}
public function getSlug(): string {
return 'my-plugin';
}
public function run() {
add_action( 'init', function () {
if ( ! class_exists( \MyPlugin::class ) ) {
return; // stay inert when the target plugin is absent
}
add_filter( 'bulk_price_editor/price_modifiers', function ( $modifiers ) {
$modifiers[] = MyPluginModifier::class;
return $modifiers;
} );
} );
}
}
add_filter( 'bulk_price_editor/integrations', function ( $integrations ) {
$integrations[] = MyPluginIntegration::class;
return $integrations;
} );
The bundled Tiered Pricing Table integration (src/Integrations/TieredPricing/) is the reference implementation: it registers a modifier, injects a preview column (replacing three narrow columns to make room), and enqueues its own stylesheet only on the editor page.
Form Control Helpers
Inside renderFields() you receive the Widget object, which provides consistent, styled controls:
renderHint( $text )— muted helper text at the top of the panel.renderTextInput( $args )— text/number/date input; supportstype,css_class,placeholder,description,desc_tip,custom_attributes.renderSelect( $args )/renderSelect2( $args )— dropdowns; select2 supports AJAX search (search_action,minimum_input_length,multiple).renderRadioButtons( $args ),renderCheckboxGroup( $args ),renderCheckbox( $args ).
Add WooCommerce’s wc_input_price CSS class to plain money fields to get store-currency formatting (not to fields that accept formulas — it strips their operators).
WP-CLI
The wp bulk-price-editor command exposes the same operations as the editor page: templates, run (with --template, or --filters / --modifier / --args as JSON, plus --dry-run and --yes), history and revert <run-id>. Runs started from the CLI are processed synchronously in chunks, recorded in the history with snapshots, and attributed to the CLI in the price log.
AJAX Endpoints
All endpoints require the plugin capability (see bulk_price_editor/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 |
Re-renders the preview table (filters + modifier posted as JSON). |
bulk_price_editor_schedule_price_updating |
wp_ajax |
Starts a bulk 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, type} for the progress screen (polled every 3s). |
bulk_price_editor_stop_price_updating |
admin_post |
Cancels all 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 / …_delete_run |
admin_post |
Cancels a scheduled run (and its automatic revert) / deletes a finished run and its snapshot. |
bulk_price_editor_export_preview / …_export_run |
admin_post |
CSV downloads of the current preview / 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 for the modal (product_id). |
bulk_price_editor_add_template / …_update_template |
wp_ajax |
Create / update a template; an optional priceModifiers JSON stores the modifier with it (a “job”). |
bulk_price_editor_remove_template / …_select_template |
admin_post |
Delete / activate a template. |
woocommerce_json_search_bpe_categories / …_bpe_attribute / …_bpe_brands / …_bpe_tags / …_bpe_shipping_classes |
wp_ajax |
Select2 lookups for the term filters. |
Background Processing Internals
- On apply, a run row is created with the filters and modifier settings. The filter query is re-run with
limit => -1, return => 'ids'— 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, since updating the parent already updates them. - Ids are chunked (
array_chunk, 100 per chunk — filterable). The first chunk is processed synchronously for instant feedback; the rest are queued viaWC()->queue()->add( 'bulk_price_editor_update_prices', array( $runId, $ids ), 'bulk-price-editor__prices' ). The ids are Action Scheduler arguments, so a chunk can no longer be lost to an evicted transient (Action Scheduler 3.5+ / WooCommerce 7.1+ is required for the larger argument size). - Before each product is saved, the modifier fires
bulk_price_editor/product/before_price_updateand the run’s snapshot row (old and new regular/sale price and sale dates) is written. A retried chunk restores the snapshot values in memory before re-applying the modifier, so a percentage change can never apply twice. - Progress is the run row’s
processedcounter, incremented after a chunk completes. The optionbpe_current_run_idpoints at the run in progress; the progress endpoint reads from it. - A revert is a run of type
revert: its chunks carry snapshot row ids of the parent run and restore only the fields the parent actually changed; each restore is snapshotted again so a revert can be reverted. - Stopping cancels pending jobs via
WC()->queue()->cancel_all()and marks the run stopped; already-processed products keep their snapshot and can still be reverted.
Data Storage
| Key | Type | Contents |
|---|---|---|
{prefix}bpe_runs |
table | One row per update/revert run: type, status, modifier type and args, filters, totals, user, timestamps (created, scheduled, revert, started, completed, reverted). |
{prefix}bpe_run_prices |
table | Per-product snapshot rows of a run: old and new regular/sale price and sale dates. A NULL “new” value means the run left that field untouched; '' means it cleared it. |
{prefix}bpe_price_log |
table | The price change log: product, timestamp, old/new value of the changed field, source (run with the run id, admin, api, cli, cron, …) and user. |
bpe_db_version |
option | Schema version; tables are created/upgraded with dbDelta on activation and on init when it changes. |
bpe_current_run_id |
option | Id of the run currently being processed (absent when idle). |
bulk_price_editor_templates |
option | All saved templates (slug => {name, filters, data}; data.modifier holds the saved price modification of a job). |
bulk_price_editor_price_notifications_{user_id} |
option | Queued snackbar notifications, per user. |
bpe_price_log_enabled, bpe_price_log_max_per_product |
options | Settings tab values. |
bpe_plugin_activation_timestamp |
option | First-activation time. |
Nothing is removed on uninstall, by design: users switch between the free and premium versions and keep their templates, history and log.
Security Model
- The editor page and all price-changing endpoints require the capability returned by
bulk_price_editor/capability(manage_optionsby default). The price-history modal on the product screen requiresedit_postfor that product. - Every write endpoint is nonce-protected; select2 lookups reuse WooCommerce’s
search-productsnonce. - Inputs are sanitized (
sanitize_text_field,wc_format_decimal, id whitelisting); the product-name search is prepared via$wpdb->preparewithesc_like, and the term clauses only ever interpolate integer ids. - Formulas are evaluated by the plugin’s own parser (
PriceModifiers\PriceFormula) — never witheval().
Internationalization
- Text domain:
bulk-price-editor, loaded oninit; translation files belong inlanguages/. - A
.pottemplate ships with the plugin; regenerate it withcomposer make-pot(WP-CLIi18n make-pot). - Strings rendered by PHP are fully translatable; the admin JavaScript receives its strings through
wp_localize_script(bulkPriceEditorData.i18n).
Debugging Tips
- The admin page JS orchestrator is exposed as
document.bulkPriceEditor— inspect collected filter values withdocument.bulkPriceEditor.filters.getAll()or the selected modifier withdocument.bulkPriceEditor.priceAdjustments.getSelected()in the browser console. - Inspect or clean up queued jobs under WooCommerce → Status → Scheduled Actions, group
bulk-price-editor__prices. Scheduled updates and automatic reverts are single actions in the same group. - The preview table request/response is visible in the network tab as
admin-ajax.phpcalls with actionbpe_load_products_table. wp bulk-price-editor historyand the History tab show the same run rows;wp bulk-price-editor run --dry-runprints what a job would do without touching anything.