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 — 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’s name attribute (repeatable name[] fields arrive as arrays).
  • Set protected $supports = array( 'advanced_options' => true ); and override renderAdvancedFields() to get a collapsible “Advanced Options” panel.
  • 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 recurses into variations for variable products.
  • 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.

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 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 input; supports 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 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 via WC()->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_progress option 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_options capability.
  • 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) and the product-name search clause is prepared via $wpdb->prepare.

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. 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 with document.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.php calls with action bpe_load_products_table.