Skip to content
Igor Radovanov
WordPress 6 min read

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: woocommerce lets WordPress 6.5+ enforce the dependency for you instead of relying on runtime guards alone.
  • Composer + PSR-4 autoloading keeps classes discoverable and testable. You don’t need a framework, just a composer.json with an autoload section.
  • 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:

  1. Push logic into the query, not into a PHP loop after the fact. Modifying WP_Query/WC_Product_Query arguments lets the database do the filtering.
  2. 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-Standards and PHPCompatibility to enforce style and catch code that breaks on your minimum PHP version.
  • PHPStan with szepeviktor/phpstan-wordpress for static analysis that understands WordPress’s loosely typed APIs.
  • PHPUnit for unit tests, plus wp-env (Docker based) for fast, disposable integration environments.
  • @wordpress/scripts if 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.