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— no raw SQL for reads or writes. - All price writes go through the WooCommerce CRUD (
set_regular_price(),set_sale_price(),save()), keeping_price, caches, and lookup tables in sync. - Bulk updates run through Action Scheduler in chunks of 100 products (queue group
bulk-price-editor__prices).
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/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/supported_product_type |
mixed — see note | Adjust supported product types. Caution: the same hook name fires with two payload shapes — an associative slug => label map (supported types) and a flat list of slugs (default-checked types). Your callback must handle both. |
bulk_price_editor/action/update_prices/products_pee_run |
int (default 100) |
Chunk size per background job. Note: the hook name contains a typo (“pee” instead of “per”) and must be used verbatim. |
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/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 |
($argumentsKey) |
The Action Scheduler job that processes one chunk of products (queue group bulk-price-editor__prices). |
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). - Set
protected $supports = array( 'advanced_options' => true );and overriderenderAdvancedFields()to get a collapsible “Advanced Options” panel. - 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 recurses into variations for variable products. - 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.
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->getUpdatedRegularPrice( $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 input; supportscss_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 money fields to get store-currency formatting.
AJAX Endpoints
| Action | Type | Purpose |
|---|---|---|
bpe_load_products_table |
wp_ajax |
Re-renders the preview table (filters + modifier posted as JSON). Nonce + manage_options. |
bulk_price_editor_schedule_price_updating |
wp_ajax |
Starts a bulk update: re-runs the filter query for all ids, chunks them, queues Action Scheduler jobs. Nonce + manage_options. |
bulk_price_editor_get_price_updating_progress |
wp_ajax |
Returns {running, total, processed} for the progress screen (polled every 3s). |
bulk_price_editor_stop_price_updating |
admin_post |
Cancels all pending chunk jobs and resets progress. Nonce + manage_options. |
bulk_price_editor_add_template / …_update_template |
wp_ajax |
Create / update a filter template (nonce-protected). |
bulk_price_editor_remove_template / …_select_template |
admin_post |
Delete / activate a filter template (nonce-protected). |
woocommerce_json_search_bpe_categories / …_bpe_attribute |
wp_ajax |
Select2 lookups for categories and attribute terms. |
Background Processing Internals
- On apply, the filter query is re-run with
limit => -1, return => 'ids'— the update targets the full filtered set at that moment. - 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', … , 'bulk-price-editor__prices' ). - Each chunk’s payload (product ids + modifier type + args) is stored in a transient (1-day TTL); only the transient key is passed to Action Scheduler.
- Progress lives in the
bulk_price_editor_price_update_progressoption and is polled by the admin JS every 3 seconds. - Stopping cancels pending jobs via
WC()->queue()->cancel_all(); already-processed chunks are not rolled back.
Data Storage
| Key | Type | Contents |
|---|---|---|
bulk_price_editor_templates |
option | All saved filter templates (slug => {name, filters, timestamps}). |
bulk_price_editor_price_update_progress |
option | {running, total, processed} for the current bulk update. |
bulk_price_editor_price_notifications |
option | Queued snackbar notifications. |
bpe_plugin_activation_timestamp |
option | First-activation time. |
{time}_{i}_update_prices_arguments |
transient (1 day) | Per-chunk background job payload. |
Security Model
- The editor page and all price-changing endpoints require the
manage_optionscapability. - Every write endpoint is nonce-protected; select2 lookups reuse WooCommerce’s
search-productsnonce. - Inputs are sanitized (
sanitize_text_field,wc_format_decimal, id whitelisting) and the product-name search clause is prepared via$wpdb->prepare.
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. A small number of strings inside the bundled admin JS are currently English-only.
Debugging Tips
- The admin page JS orchestrator is exposed as
document.bulkPriceEditor— inspect collected filter values withdocument.bulkPriceEditor.filters.getAll()in the browser console. - Inspect or clean up queued jobs under WooCommerce → Status → Scheduled Actions, group
bulk-price-editor__prices. - The preview table request/response is visible in the network tab as
admin-ajax.phpcalls with actionbpe_load_products_table.