Blog
How to build a WordPress plugin: PHP, WooCommerce and Elementor
Build a WordPress plugin step by step: structure, hooks, security, a WooCommerce shipping rule, an Elementor widget and HPOS compatibility, with PHP code.

A lot of WordPress customization ends up in functions.php. It works until the theme changes and the feature disappears with it. Anything that has to outlive the theme, like a shipping rule, a pricing rule or a custom widget, belongs in a plugin.
This guide builds a small plugin the way I structure client work: a clean entry file, hooks instead of edits, security by default, a WooCommerce rule and an Elementor widget. The WooCommerce part mirrors the kind of work I did at Industria Social for GroovePro, where shipping rules and bundle pricing were kept in two separate PHP plugins.
The examples use a made-up plugin called Acme Shipping Rules. Rename the prefix (acme_sr_) to your own.
Create wp-content/plugins/acme-shipping-rules/acme-shipping-rules.php as the main file and add the hooks and functions from steps 2 to 5 there. Put the widget in widgets/class-acme-savings-widget.php and cleanup in uninstall.php. Blocks continuing the main file do not need another opening <?php. The SQL query in step 3 is a separate example, not part of the shipping plugin.
Step 1: the plugin file and its header
A plugin is a folder in wp-content/plugins/ with a main PHP file. Only Plugin Name is required in the header, but a real plugin should declare more, according to the WordPress plugin header requirements:
<?php
/**
* Plugin Name: Acme Shipping Rules
* Description: Adjusts WooCommerce shipping rates by package quantity.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 8.3
* Author: Fernando Peralta
* License: GPL v2 or later
* Text Domain: acme-shipping-rules
* Requires Plugins: woocommerce
*/
// Block direct access to the file.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
Requires Plugins lets WordPress refuse to activate your plugin when WooCommerce is missing (WordPress 6.5 and later). The ABSPATH guard stops anyone from loading the file outside WordPress.
This example declares WordPress 6.5 as the minimum for Requires Plugins and PHP 8.3 as its baseline. The installed WooCommerce version may require a higher WordPress version; check its environment requirements. Elementor is optional: without it, the plugin keeps the shipping rule and does not register the widget.
Two habits that prevent most collisions between plugins:
- Prefix everything: functions, options, hooks and class names (
acme_sr_). - Use a text domain and wrap strings in
__()oresc_html__()from day one, so translating later is not a refactor.
Step 2: hooks, not edits
WordPress is event driven. You never edit core or another plugin: you attach to actions (do something at a moment) and filters (change a value on its way through).
// Run something when the plugin is activated.
register_activation_hook( __FILE__, 'acme_sr_activate' );
function acme_sr_activate() {
add_option( 'acme_sr_threshold', 3 );
}
register_activation_hook() only fires on activation, which makes it the right place to create default options. Keep any heavier setup, like creating tables, there rather than on every request.
Step 3: security is not optional
The handler checks permissions, verifies the nonce and validates an integer of at least 1 on the server. min="1" helps in the browser but does not replace this validation. Arrays, missing fields, text, decimals and values below 1 are rejected without changing the option:
add_action( 'admin_post_acme_sr_save', 'acme_sr_save_settings' );
function acme_sr_save_settings() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Not allowed.', 'acme-shipping-rules' ), 403 );
}
// Verify the CSRF token; it does not replace permissions.
check_admin_referer( 'acme_sr_save' );
// Validate the type and range before saving.
$raw = isset( $_POST['threshold'] ) && is_string( $_POST['threshold'] )
? wp_unslash( $_POST['threshold'] )
: '';
$threshold = filter_var( $raw, FILTER_VALIDATE_INT, [
'options' => [ 'min_range' => 1 ],
] );
if ( false === $threshold ) {
wp_die( esc_html__( 'The quantity threshold must be an integer of at least 1.', 'acme-shipping-rules' ), 400 );
}
update_option( 'acme_sr_threshold', $threshold );
wp_safe_redirect( admin_url( 'admin.php?page=acme-shipping-rules' ) );
exit;
}
Register a page under WooCommerce → Shipping rules with admin_menu and add_submenu_page(). This block belongs in the main file and includes the function that renders the form:
add_action( 'admin_menu', 'acme_sr_register_settings_page' );
function acme_sr_register_settings_page() {
add_submenu_page(
'woocommerce',
esc_html__( 'Shipping rules', 'acme-shipping-rules' ),
esc_html__( 'Shipping rules', 'acme-shipping-rules' ),
'manage_woocommerce',
'acme-shipping-rules',
'acme_sr_render_settings_page'
);
}
function acme_sr_render_settings_page() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'Not allowed.', 'acme-shipping-rules' ), 403 );
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Shipping rules', 'acme-shipping-rules' ); ?></h1>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="acme_sr_save">
<?php wp_nonce_field( 'acme_sr_save' ); ?>
<label for="threshold"><?php esc_html_e( 'Quantity threshold per package', 'acme-shipping-rules' ); ?></label>
<input id="threshold" name="threshold" type="number" min="1" step="1" required
value="<?php echo esc_attr( get_option( 'acme_sr_threshold', 3 ) ); ?>">
<button type="submit"><?php esc_html_e( 'Save', 'acme-shipping-rules' ); ?></button>
</form>
</div>
<?php
}
Notes worth remembering:
- A nonce helps protect against CSRF; it does not prove a request’s origin or grant permissions on its own. Also use
current_user_can(), as explained in the WordPress nonce documentation. - Escape at the moment of output:
esc_html(),esc_attr(),esc_url(). Sanitizing on the way in does not make output safe. - Any custom SQL goes through
$wpdb->prepare():
global $wpdb;
$table = $wpdb->prefix . 'acme_sr_log';
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$table} WHERE order_id = %d", $order_id )
);
Step 4: a WooCommerce shipping rule
The woocommerce_package_rates filter receives the rates and one shipping package. This example discounts the cost and its taxes by 20% when that package reaches the quantity threshold. If a cart is split into multiple packages, each is evaluated separately; products across the entire cart are not added together.
// Include the rule in the package hash to avoid stale rates.
add_filter( 'woocommerce_cart_shipping_packages', 'acme_sr_shipping_rule_cache_key' );
function acme_sr_shipping_rule_cache_key( $packages ) {
$threshold = max( 1, (int) get_option( 'acme_sr_threshold', 3 ) );
foreach ( $packages as $key => $package ) {
$packages[ $key ]['acme_sr_threshold'] = $threshold;
}
return $packages;
}
add_filter( 'woocommerce_package_rates', 'acme_sr_adjust_rates', 10, 2 );
function acme_sr_adjust_rates( $rates, $package ) {
$quantity = 0;
foreach ( $package['contents'] as $item ) {
$quantity += (int) $item['quantity'];
}
if ( $quantity < max( 1, (int) get_option( 'acme_sr_threshold', 3 ) ) ) {
return $rates;
}
foreach ( $rates as $rate ) {
// 20% off if this package reaches the quantity threshold.
$rate->set_cost( (float) $rate->get_cost() * 0.8 );
// Adjust taxes proportionally to the discounted cost.
$taxes = array_map(
fn( $tax ) => $tax * 0.8,
$rate->get_taxes()
);
$rate->set_taxes( $taxes );
}
return $rates;
}
$rate is a WC_Shipping_Rate, which exposes get_cost() and set_cost(), plus get_taxes() and set_taxes(). Two practical warnings:
- The cache must reflect the rule.
acme_sr_shipping_rule_cache_key()adds the quantity threshold to each package before rates are calculated. Saving another threshold changes the hash, so WooCommerce recalculates on the customer’s next request. The value is stable: it does not invalidate the cache on every visit. WooCommerce’s shipping code shows how the package contributes to the hash. - Test with taxes on and off, with several shipping zones, and with coupons that grant free shipping.
Declare HPOS compatibility
WooCommerce’s High-Performance Order Storage changes where orders live. A plugin that does not declare compatibility is not marked as compatible in WooCommerce’s feature settings, so review its code before declaring compatibility from the main file:
add_action( 'before_woocommerce_init', function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
} );
Declaring it is a promise: read and write orders through WooCommerce CRUD methods (wc_get_order(), $order->get_meta()), not direct wp_postmeta queries.
Step 5: an Elementor widget
On sites built with Elementor, a client often wants a block they can drop into any page. A widget is a PHP class that extends \Elementor\Widget_Base, following the Elementor widget structure:
<?php
// widgets/class-acme-savings-widget.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Acme_Savings_Widget extends \Elementor\Widget_Base {
public function get_name(): string {
return 'acme_savings';
}
public function get_title(): string {
return esc_html__( 'Shipping savings', 'acme-shipping-rules' );
}
public function get_icon(): string {
return 'eicon-code';
}
public function get_categories(): array {
return [ 'general' ];
}
protected function register_controls(): void {
$this->start_controls_section( 'content_section', [
'label' => esc_html__( 'Content', 'acme-shipping-rules' ),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
] );
$this->add_control( 'message', [
'label' => esc_html__( 'Message', 'acme-shipping-rules' ),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => esc_html__( 'Shipping discounts are calculated per package.', 'acme-shipping-rules' ),
] );
$this->end_controls_section();
}
protected function render(): void {
$settings = $this->get_settings_for_display();
echo '<p class="acme-savings">' . esc_html( $settings['message'] ) . '</p>';
}
}
Register it from the main plugin file. The class file is required inside the hook, because Elementor’s classes do not exist until Elementor has loaded:
add_action( 'elementor/widgets/register', function ( $widgets_manager ) {
require_once __DIR__ . '/widgets/class-acme-savings-widget.php';
$widgets_manager->register( new \Acme_Savings_Widget() );
} );
The widget now appears in the Elementor panel, its message control is editable by the client, and the output is escaped.
The widget is informational: its message is edited in Elementor and does not determine discount eligibility. The rule is applied when shipping is calculated for each package.
Step 6: performance and maintenance
A plugin runs on every request, so be careful about what you load:
- Load assets only where they are needed. Enqueue scripts and styles inside
wp_enqueue_scriptsand guard with conditionals such asis_cart()oris_checkout(). - Do not query on every page. Cache expensive results with transients and invalidate them when data changes.
- Clean up on uninstall. Remove only plugin-owned data; do not delete orders or other plugins’ data.
- Version your assets with the plugin version so browsers do not serve stale files after a release.
For this example, uninstall.php removes only the plugin option on uninstall. Use WP_UNINSTALL_PLUGIN as the guard in that file; deactivation should not delete the configuration:
<?php
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
delete_option( 'acme_sr_threshold' );
Before delivering, run the code through the WordPress Coding Standards with PHP_CodeSniffer, and the official Plugin Check plugin, which flags security and compatibility issues.
Pre-release checklist
- Complete header,
ABSPATHguards in the main file and widget, andWP_UNINSTALL_PLUGINinuninstall.php. - All functions, options and classes prefixed.
- Permissions, nonce and server-side validation of the minimum integer; escaped output in the form and widget.
$wpdb->prepare()on any SQL with variables.- HPOS declared, and orders read through WooCommerce APIs.
- Assets enqueued only where needed.
- Tests below/at/above the threshold, multiple packages, taxes, zones, free shipping and a threshold change in a session with cached rates.
uninstall.phpand a changelog in place.
When to build instead of buying
A ready-made plugin is the right call when it does exactly what you need and is actively maintained. A custom plugin earns its cost when the rule is specific to your business, like a shipping formula or a bundle price, or when you would otherwise pile three plugins together to approximate it.
This is the work I do as a WordPress developer in Mexico City. You can see related projects in the case studies, such as the bilingual WordPress and Elementor site for Mezcal Viviente. If you need a custom plugin, write to me with the rule you want to automate.
Loading post views…