Developer Documentation

Hooks, extension points, and internals for developers extending Bulk Price Editor.

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 ProductQuery that is executed as a standard WC_Product_Query. Term-based filters (categories, brands, tags, shipping classes) add a variation-aware posts_where clause 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’s name attribute (repeatable name[] 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 override renderAdvancedFields() to add your own fields.
  • Override updatePrices( \WC_Product $product ): void if 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, fire do_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 an init callback.

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 orderby whitelist in WPListProductsTable::addSorting() is hard-coded, so a custom sortable column also needs its own query adjustment (e.g. via bulk_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; supports type, 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 via WC()->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_update and 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 processed counter, incremented after a chunk completes. The option bpe_current_run_id points 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_options by default). The price-history modal on the product screen requires edit_post for that product.
  • Every write endpoint is nonce-protected; select2 lookups reuse WooCommerce’s search-products nonce.
  • Inputs are sanitized (sanitize_text_field, wc_format_decimal, id whitelisting); the product-name search is prepared via $wpdb->prepare with esc_like, and the term clauses only ever interpolate integer ids.
  • Formulas are evaluated by the plugin’s own parser (PriceModifiers\PriceFormula) — never with eval().

Internationalization

  • Text domain: bulk-price-editor, loaded on init; translation files belong in languages/.
  • A .pot template ships with the plugin; regenerate it with composer make-pot (WP-CLI i18n 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 with document.bulkPriceEditor.filters.getAll() or the selected modifier with document.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.php calls with action bpe_load_products_table.
  • wp bulk-price-editor history and the History tab show the same run rows; wp bulk-price-editor run --dry-run prints what a job would do without touching anything.