Writing WordPress Plugins That Survive Contact With Production
A practical guide to building modern, maintainable WooCommerce plugins: architecture, security, performance, HPOS compatibility, tooling, and the unglamorous details that actually matter.
Publishing a plugin on WordPress.org is a humbling exercise. The moment strangers install your code on sites you’ll never see, all the shortcuts you might take in a private project stop being acceptable. Maintaining Product Roles Manager for WooCommerce taught me that the hard parts of plugin development are rarely the features. They’re the architecture, the compatibility surface, and the boring details that only show up at scale.
This is the guide I wish I’d had: how I build modern WooCommerce plugins that hold up in production.
Structure the plugin like real software
The biggest mistake I see in WooCommerce plugins is treating them as a single
functions.php style file full of hooks. Plugins are real software and deserve
real structure: namespaces, autoloading, and a clear separation between
bootstrapping and logic.
<?php
/**
* Plugin Name: Product Roles Manager for WooCommerce
* Requires Plugins: woocommerce
* Requires at least: 6.5
* Requires PHP: 7.4
*/
defined( 'ABSPATH' ) || exit;
require __DIR__ . '/vendor/autoload.php';
add_action( 'plugins_loaded', static function () {
if ( ! class_exists( 'WooCommerce' ) ) {
return; // Fail quietly if the dependency isn't active.
}
( new \IR\ProductRoles\Plugin() )->boot();
} );
A few things worth highlighting:
Requires Plugins: woocommercelets WordPress 6.5+ enforce the dependency for you instead of relying on runtime guards alone.- Composer +
PSR-4autoloading keeps classes discoverable and testable. You don’t need a framework, just acomposer.jsonwith anautoloadsection. - Bootstrap on
plugins_loaded, after you’ve confirmed WooCommerce is present, so you never fatal a site by assuming a class that isn’t there.
Hook into WooCommerce, don’t fight it
WooCommerce is a deeply hookable system. Modern plugins extend it through its filters and actions rather than overriding templates or patching core behavior. Build features as small, focused handlers registered against well known hooks:
final class CatalogVisibility {
public function register(): void {
add_action( 'woocommerce_product_query', [ $this, 'restrict_query' ] );
add_filter( 'woocommerce_is_purchasable', [ $this, 'gate_purchase' ], 10, 2 );
}
public function restrict_query( \WP_Query $query ): void {
if ( current_user_can( 'manage_woocommerce' ) ) {
return; // Don't restrict the people who manage the store.
}
$query->set( 'post__not_in', $this->hidden_product_ids() );
}
}
Keeping each concern in its own class with a single register() method makes the
plugin easy to reason about and trivial to unit test.
Be HPOS compatible from day one
The single biggest modern WooCommerce concern is High Performance Order Storage
(HPOS): orders now live in custom tables instead of the wp_posts graveyard.
If your plugin touches orders, it must declare compatibility and use the CRUD API
instead of post meta.
add_action( 'before_woocommerce_init', static function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
} );
Then read and write through objects, never raw get_post_meta() on an order ID:
$order = wc_get_order( $order_id );
$order->update_meta_data( '_ir_access_tier', 'wholesale' );
$order->save();
The CRUD layer works whether the store uses HPOS or legacy storage, so you write the code once and stay forward compatible.
Security is not optional
WordPress runs an enormous share of the web, which makes plugins a favorite target. The non negotiables, every single time:
- Sanitize on input, escape on output, no exceptions.
- Check capabilities before privileged actions, not just nonces.
- Use nonces to defend against CSRF on state changing requests.
- Use
$wpdb->prepare()for any query that touches user input.
// Verify intent (nonce) AND authority (capability) before acting.
if ( ! current_user_can( 'manage_woocommerce' )
|| ! wp_verify_nonce( $_POST['_ir_nonce'] ?? '', 'ir_save_roles' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'product-roles-manager' ) );
}
$tier = sanitize_key( wp_unslash( $_POST['tier'] ?? '' ) );
// Escape on output, always, pick the escaper that matches the context.
echo esc_html( $product_name );
printf( '<a href="%s">%s</a>', esc_url( $link ), esc_html( $label ) );
The rule of thumb: never trust input, never emit unescaped output. Sanitization
and escaping are context specific: esc_html, esc_attr, esc_url, and
wp_kses are not interchangeable.
Performance: queries are where it goes to die
Access control and catalog logic has to apply everywhere products appear: shop pages, search, related products, widgets, and the REST API. Done naively, that’s a fast track to N+1 queries that quietly destroy performance on a store with thousands of products.
Two habits keep it fast:
- Push logic into the query, not into a PHP loop after the fact. Modifying
WP_Query/WC_Product_Queryarguments lets the database do the filtering. - Cache expensive lookups with the object cache so you compute them once per request, or once across requests when a persistent cache is available.
private function hidden_product_ids(): array {
$cache_key = 'ir_hidden_products_' . get_current_user_id();
$ids = wp_cache_get( $cache_key, 'product_roles' );
if ( false === $ids ) {
$ids = $this->compute_hidden_products();
wp_cache_set( $cache_key, $ids, 'product_roles', HOUR_IN_SECONDS );
}
return $ids;
}
Always remember to invalidate the cache when the underlying data changes. Stale catalog visibility is a support ticket waiting to happen.
Settings the modern way
Store configuration belongs in a single, well namespaced option rather than scattered across dozens of rows that bloat the autoloaded options table. For UI, the WooCommerce Settings API gives you a native looking settings tab for free:
add_filter( 'woocommerce_get_settings_pages', function ( $pages ) {
$pages[] = new \IR\ProductRoles\SettingsPage();
return $pages;
} );
Keeping settings inside WooCommerce’s own UI makes the plugin feel like a first class citizen instead of a bolt on.
Internationalize everything
Every user facing string goes through a translation function with a consistent
text domain that matches your plugin slug. Since WordPress 6.5 you don’t even need
to call load_plugin_textdomain() for plugins hosted on WordPress.org, just use
the functions correctly:
esc_html_e( 'Restrict this product by role', 'product-roles-manager' );
Tooling: treat it like a real project
Modern WordPress development has real tooling. The setup I reach for:
- PHP_CodeSniffer with
WordPress-Coding-Standardsand PHPCompatibility to enforce style and catch code that breaks on your minimum PHP version. - PHPStan with
szepeviktor/phpstan-wordpressfor static analysis that understands WordPress’s loosely typed APIs. - PHPUnit for unit tests, plus
wp-env(Docker based) for fast, disposable integration environments. @wordpress/scriptsif the plugin ships any block or admin JavaScript, so you get a zero config build and the same standards as core.
composer run phpcs # coding standards + compatibility
composer run phpstan # static analysis
wp-env run tests-cli phpunit # tests against a real WP + WooCommerce
None of this is mandatory to ship, but it’s the difference between a plugin you’re nervous to touch and one you can refactor with confidence.
Uninstall cleanly
A plugin that leaves orphaned options, tables, and scheduled events behind is a
bad guest. Honor the lifecycle hooks, and put real teardown logic in
uninstall.php, which only runs on actual deletion:
// uninstall.php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
delete_option( 'ir_product_roles_settings' );
wp_clear_scheduled_hook( 'ir_product_roles_recalc' );
The unglamorous truth
Features get the attention, but plugins live or die by the boring details: architecture, capabilities, HPOS compatibility, queries, caching, i18n, and clean teardown. Getting those right is what separates code that survives production from code that becomes someone’s support ticket, and it’s exactly the discipline that publishing on WordPress.org forces you to build.