AยทR
EN SR

Building WordPress Plugins That Do Not Break

Production-grade WordPress plugin architecture with PSR-4 autoloading, proper activation hooks, database migrations, nonce verification, and caching patterns that survive real-world usage.

Building WordPress Plugins That Do Not Break

File Structure That Scales

A well-structured plugin separates concerns from day one. Use this directory layout:

my-plugin/
โ”œโ”€โ”€ my-plugin.php              # Main plugin file โ€” WordPress entry point
โ”œโ”€โ”€ composer.json              # PSR-4 autoloading, dependencies
โ”œโ”€โ”€ uninstall.php              # Cleanup on uninstall (not deactivation)
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ Plugin.php             # Main plugin class โ€” initialization
โ”‚   โ”œโ”€โ”€ Admin/
โ”‚   โ”‚   โ”œโ”€โ”€ Admin.php          # Admin-facing functionality
โ”‚   โ”‚   โ””โ”€โ”€ SettingsPage.php   # Settings page registration
โ”‚   โ”œโ”€โ”€ Frontend/
โ”‚   โ”‚   โ””โ”€โ”€ Frontend.php       # Public-facing functionality
โ”‚   โ”œโ”€โ”€ Database/
โ”‚   โ”‚   โ”œโ”€โ”€ Installer.php      # Table creation on activation
โ”‚   โ”‚   โ””โ”€โ”€ Migrations.php     # Schema version tracking
โ”‚   โ”œโ”€โ”€ API/
โ”‚   โ”‚   โ””โ”€โ”€ RESTController.php # Custom REST API endpoints
โ”‚   โ””โ”€โ”€ Utils/
โ”‚       โ””โ”€โ”€ Cache.php          # Transient wrapper utilities
โ”œโ”€โ”€ assets/
โ”‚   โ”œโ”€โ”€ css/
โ”‚   โ”‚   โ””โ”€โ”€ admin.css
โ”‚   โ””โ”€โ”€ js/
โ”‚       โ”œโ”€โ”€ admin.js
โ”‚       โ””โ”€โ”€ frontend.js
โ””โ”€โ”€ templates/
    โ””โ”€โ”€ admin-settings.php

The src/ directory contains all PHP classes. The assets/ directory holds CSS and JS files. The templates/ directory contains PHP view files that get loaded by class methods. Nothing in src/ executes directly โ€” all entry points go through my-plugin.php or WordPress hooks.

Namespacing and PSR-4 Autoloading

Use proper namespacing to avoid class name collisions. The composer.json autoloading configuration:

{
    "name": "ardev/my-plugin",
    "autoload": {
        "psr-4": {
            "ARDev\MyPlugin\": "src/"
        }
    },
    "require": {
        "php": ">=8.1"
    }
}

After creating composer.json, run composer install to generate the autoloader. The main plugin file then becomes minimal:

<?php
/**
 * Plugin Name: My Plugin
 * Description: A production-grade WordPress plugin.
 * Version: 1.0.0
 * Author: Aleksandar Rasevic Development
 * Text Domain: ardev-my-plugin
 * Domain Path: /languages
 * Requires PHP: 8.1
 */

if (!defined('ABSPATH')) {
    exit;
}

require_once __DIR__ . '/vendor/autoload.php';

use ARDevMyPluginPlugin;

define('ARDEV_MY_PLUGIN_VERSION', '1.0.0');
define('ARDEV_MY_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('ARDEV_MY_PLUGIN_URL', plugin_dir_url(__FILE__));

// Initialize
Plugin::instance();

The namespace ARDevMyPlugin maps to the src/ directory. A class at src/Admin/SettingsPage.php uses namespace ARDevMyPluginAdmin and class name SettingsPage. The autoloader handles the translation automatically.

Main Plugin Class with Singleton Pattern

<?php
namespace ARDevMyPlugin;

use ARDevMyPluginAdminAdmin;
use ARDevMyPluginDatabaseInstaller;

class Plugin {
    private static ?self $instance = null;

    public static function instance(): self {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        $this->init();
    }

    private function init(): void {
        add_action('plugins_loaded', [$this, 'load']);
    }

    public function load(): void {
        if (is_admin()) {
            new Admin();
        }
    }
}

// Prevent cloning and unserialization
private function __clone() {}
public function __wakeup() {
    throw new Exception('Cannot unserialize singleton');
}

The singleton prevents multiple instances from conflicting. All initialization runs through the plugins_loaded hook, which fires after all plugins are loaded but before the theme is set up. This is the correct hook for plugin initialization โ€” not init, which fires after the theme.

Activation and Deactivation Hooks

The main plugin file registers activation and deactivation hooks:

// At the bottom of my-plugin.php, after Plugin::instance()
register_activation_hook(__FILE__, function() {
    require_once __DIR__ . '/vendor/autoload.php';
    ARDevMyPluginDatabaseInstaller::activate();
    flush_rewrite_rules();
});

register_deactivation_hook(__FILE__, function() {
    wp_clear_scheduled_hook('ardev_my_plugin_cron');
    flush_rewrite_rules();
});

The Installer class handles table creation:

<?php
namespace ARDevMyPluginDatabase;

class Installer {
    private static string $db_version = '1.0.0';

    public static function activate(): void {
        global $wpdb;
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}ardev_entries (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            title varchar(255) NOT NULL,
            content longtext,
            status varchar(20) NOT NULL DEFAULT 'active',
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY status (status),
            KEY created_at (created_at)
        ) {$charset_collate};";

        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        dbDelta($sql);

        update_option('ardev_my_plugin_db_version', self::$db_version);
    }
}

Always use dbDelta() for table creation โ€” it handles charset collation properly and is idempotent. The flush_rewrite_rules() call is necessary if your plugin registers custom post types or rewrite endpoints. On deactivation, flush rules again and clear any scheduled cron events your plugin registered.

uninstall.php for Clean Removal

Create uninstall.php in the plugin root. WordPress executes this file when the plugin is deleted (not deactivated โ€” deleted):

<?php
if (!defined('WP_UNINSTALL_PLUGIN')) {
    exit;
}

// Only clean up if explicitly enabled
if (get_option('ardev_my_plugin_remove_data_on_uninstall') !== 'yes') {
    return;
}

global $wpdb;

// Drop custom tables
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}ardev_entries");

// Delete all plugin options
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE 'ardev_my_plugin_%'");

// Delete all transients
$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_ardev_%' OR option_name LIKE '_transient_timeout_ardev_%'");

// Clear scheduled events
wp_clear_scheduled_hook('ardev_my_plugin_cron');

The guard at the top prevents direct access. The option check protects users who deactivate temporarily โ€” data deletion must be opt-in via a settings toggle. Always clean up transients with the _transient_timeout_ prefix too, or you leave orphaned rows in the options table.

Database Versioning and Schema Migrations

Your plugin will need schema changes after the initial release. Track versions and run migrations:

<?php
namespace ARDevMyPluginDatabase;

class Migrations {
    private static string $current_version = '1.1.0';

    public static function run(): void {
        $installed_version = get_option('ardev_my_plugin_db_version', '0.0.0');

        if (version_compare($installed_version, self::$current_version, '>=')) {
            return;
        }

        if (version_compare($installed_version, '1.1.0', '<')) {
            self::migrate_to_1_1_0();
        }

        update_option('ardev_my_plugin_db_version', self::$current_version);
    }

    private static function migrate_to_1_1_0(): void {
        global $wpdb;
        $wpdb->query("ALTER TABLE {$wpdb->prefix}ardev_entries ADD COLUMN priority int(11) NOT NULL DEFAULT 0 AFTER status");
        $wpdb->query("ALTER TABLE {$wpdb->prefix}ardev_entries ADD INDEX priority (priority)");
    }
}

Call Migrations::run() from your plugin’s load() method. The version check ensures each migration runs exactly once. Use dbDelta() for CREATE TABLE statements and direct $wpdb->query() for ALTER TABLE operations.

AJAX with Nonce Verification

Every AJAX handler must verify the nonce and user capabilities:

// In your admin class, register the action
add_action('wp_ajax_ardev_save_entry', [$this, 'handle_save_entry']);

public function handle_save_entry(): void {
    // 1. Verify nonce
    if (!wp_verify_nonce($_POST['_ajax_nonce'] ?? '', 'ardev_save_entry')) {
        wp_send_json_error('Invalid security token.', 403);
    }

    // 2. Check user capability
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Insufficient permissions.', 403);
    }

    // 3. Sanitize input
    $title = sanitize_text_field(wp_unslash($_POST['title'] ?? ''));
    $content = wp_kses_post(wp_unslash($_POST['content'] ?? ''));

    if (empty($title)) {
        wp_send_json_error('Title is required.', 400);
    }

    // 4. Process and respond
    global $wpdb;
    $result = $wpdb->insert(
        $wpdb->prefix . 'ardev_entries',
        [
            'title' => $title,
            'content' => $content,
        ],
        ['%s', '%s']
    );

    if ($result === false) {
        wp_send_json_error('Database error.', 500);
    }

    wp_send_json_success([
        'id' => $wpdb->insert_id,
        'message' => 'Entry saved successfully.',
    ]);
}

The nonce check (wp_verify_nonce) prevents CSRF attacks. The capability check (current_user_can) ensures only authorized users can execute the action. The order matters: verify nonce first, then capabilities, then sanitize input, then process. Always use wp_send_json_error() and wp_send_json_success() โ€” they handle the JSON encoding and die() call for you.

Enqueue Scripts with Filemtime Versioning

Cache-busting by file modification time ensures browsers load fresh assets after deployment:

public function enqueue_admin_assets(string $hook): void {
    // Only load on our plugin's admin page
    if ($hook !== 'toplevel_page_ardev-my-plugin') {
        return;
    }

    $css_path = ARDEV_MY_PLUGIN_DIR . 'assets/css/admin.css';
    $js_path = ARDEV_MY_PLUGIN_DIR . 'assets/js/admin.js';

    wp_enqueue_style(
        'ardev-admin-css',
        ARDEV_MY_PLUGIN_URL . 'assets/css/admin.css',
        [],
        file_exists($css_path) ? (string) filemtime($css_path) : ARDEV_MY_PLUGIN_VERSION
    );

    wp_enqueue_script(
        'ardev-admin-js',
        ARDEV_MY_PLUGIN_URL . 'assets/js/admin.js',
        ['jquery'],
        file_exists($js_path) ? (string) filemtime($js_path) : ARDEV_MY_PLUGIN_VERSION,
        true // Load in footer
    );

    // Localize script with AJAX URL and nonce
    wp_localize_script('ardev-admin-js', 'ardev_ajax', [
        'ajax_url' => admin_url('admin-ajax.php'),
        'nonce'    => wp_create_nonce('ardev_save_entry'),
    ]);
}

The filemtime() call produces a version string like 1698321456. When the file changes, the version changes and browsers fetch the new copy. The $hook check prevents loading assets on every admin page โ€” a common performance mistake in plugin development.

Option Storage: Arrays vs Individual Options

WordPress stores each option as a separate row in wp_options. For related settings, use a single array option instead of multiple individual options:

// BAD: Creates one row per setting
update_option('ardev_api_key', 'abc123');
update_option('ardev_api_endpoint', 'https://api.example.com');
update_option('ardev_api_timeout', 30);

// GOOD: One row, one database query
update_option('ardev_api_settings', [
    'api_key'    => 'abc123',
    'endpoint'   => 'https://api.example.com',
    'timeout'    => 30,
    'updated_at' => time(),
]);

// Retrieval with defaults
$settings = get_option('ardev_api_settings', [
    'api_key'    => '',
    'endpoint'   => 'https://api.example.com',
    'timeout'    => 30,
]);

Individual options create bloat in wp_options. Every option check is a database query unless object caching is active. A single array option reduces queries from N to 1 and keeps related data together. Add a dedicated settings class with typed getters and setters for clean access.

Cron Job Registration

Register custom cron schedules and events safely:

<?php
namespace ARDevMyPlugin;

class Cron {
    public function register(): void {
        add_filter('cron_schedules', [$this, 'add_interval']);

        if (!wp_next_scheduled('ardev_my_plugin_sync')) {
            wp_schedule_event(time(), 'every_fifteen_minutes', 'ardev_my_plugin_sync');
        }

        add_action('ardev_my_plugin_sync', [$this, 'run_sync']);
    }

    public function add_interval(array $schedules): array {
        $schedules['every_fifteen_minutes'] = [
            'interval' => 15 * MINUTE_IN_SECONDS,
            'display'  => __('Every 15 Minutes', 'ardev-my-plugin'),
        ];
        return $schedules;
    }

    public function run_sync(): void {
        // Verify this is actually a cron request
        if (!defined('DOING_CRON') || !DOING_CRON) {
            return;
        }

        // Set a lock to prevent overlapping runs
        $lock = get_transient('ardev_sync_lock');
        if ($lock) {
            return; // Another process is running
        }
        set_transient('ardev_sync_lock', true, 10 * MINUTE_IN_SECONDS);

        try {
            // Your sync logic here
        } finally {
            delete_transient('ardev_sync_lock');
        }
    }
}

The wp_next_scheduled() guard prevents duplicate event registration โ€” without it, every plugin load creates a new scheduled event. The transient lock prevents the same cron job from running concurrently, which causes race conditions on long-running sync operations. The try/finally block ensures the lock is always released, even if the sync throws an exception.

Transients for Caching

Wrap expensive operations in transients. If object caching (Redis/Memcached) is active, transients use it automatically. If not, they fall back to the options table:

<?php
namespace ARDevMyPluginUtils;

class Cache {
    public static function get(string $key, callable $callback, int $expiration = HOUR_IN_SECONDS) {
        $value = get_transient($key);

        if ($value !== false) {
            return $value;
        }

        $value = $callback();
        set_transient($key, $value, $expiration);

        return $value;
    }

    public static function delete(string $pattern): void {
        global $wpdb;
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
            $wpdb->esc_like('_transient_' . $pattern) . '%',
            $wpdb->esc_like('_transient_timeout_' . $pattern) . '%'
        ));
        
        // Also purge from external object cache
        wp_cache_flush();
    }
}

Usage:

$entries = Cache::get('ardev_all_active_entries', function() {
    global $wpdb;
    return $wpdb->get_results(
        "SELECT * FROM {$wpdb->prefix}ardev_entries WHERE status = 'active' ORDER BY created_at DESC"
    );
}, 5 * MINUTE_IN_SECONDS);

The transient API abstracts away whether Redis or the database is being used. Your code stays the same regardless of the caching backend. Always delete both the transient value and its timeout row when manually clearing cache โ€” WordPress stores expiration metadata separately.

Leave a Reply

Your email address will not be published. Required fields are marked *