Skip to main content

🧩 Plugin Development Architecture

A plugin is how you add features to WordPress without touching a single line of core code. In this lesson you'll build a mental model of what a plugin actually is β€” from a one-file experiment to a well-organized, object-oriented, secure plugin that other developers can safely extend.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a WordPress plugin is and why plugins never modify core files
  • Write a valid plugin header and lay out a maintainable file structure
  • Structure a plugin with object-oriented code and manage its lifecycle (activate / deactivate / uninstall)
  • Choose the right data storage method (Options API, custom tables, custom post types)
  • Apply security and extensibility patterns from the very first commit

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Scaffold a working, object-oriented plugin with activation and deactivation logic.

In This Lesson

What Is a Plugin?

A WordPress plugin is a self-contained collection of files that adds features to a site β€” without editing WordPress core. This separation is the golden rule of the platform: core stays untouched so it can be safely updated, while your plugin layers new behaviour on top.

πŸ’‘ Analogy: WordPress core is like a smartphone's operating system, and plugins are the apps you install. You would never rewire the phone's motherboard to add a calculator β€” you install an app. When the OS updates, your apps keep working because they plug into stable, published interfaces rather than the internals.

Plugins hook into WordPress at defined moments in its execution using actions and filters (the subject of the next lesson). A good plugin is judged by five qualities:

  • Modularity β€” it does its job without colliding with other plugins.
  • Extensibility β€” other developers can build on it via its own hooks.
  • Integration β€” it uses WordPress APIs instead of reinventing them.
  • Security β€” it validates, sanitizes, and checks permissions everywhere.
  • Performance β€” it adds minimal weight to each page load.
flowchart TB A[WordPress Core] --> B[Plugin API
actions & filters] B --> C[Your Plugin] C --> D[Custom functionality] C --> E[Database interaction] C --> F[Admin interface] C --> G[Front-end output]

Object-Oriented Plugin Design

Sprinkling loose functions across files works until it doesn't β€” every function name has to be globally unique, and shared state becomes a tangle. Modern plugins wrap their logic in classes. A single orchestrating class acts as the control centre, delegating admin work and public work to dedicated classes.

Object-oriented plugin structure A main plugin class sits at the top and delegates to an Admin class and a Public class, while pulling in a shared Loader and Helpers. My_Plugin orchestrator Admin menus, settings admin assets Public shortcodes front-end assets Loader / Helpers shared services used everywhere
Figure 1 β€” The main class coordinates focused Admin and Public classes and shares common services. Each class has a single, clear responsibility.

The main plugin class

<?php
class My_Plugin {

    private string $plugin_name = 'my-plugin';
    private string $version     = '1.0.0';

    public function __construct() {
        $this->load_dependencies();
        $this->set_locale();
        $this->define_admin_hooks();
        $this->define_public_hooks();
    }

    private function load_dependencies(): void {
        // require_once each class file the plugin needs.
    }

    private function set_locale(): void {
        // Load the text domain for translations.
    }

    private function define_admin_hooks(): void {
        $admin = new My_Plugin_Admin( $this->plugin_name, $this->version );
        add_action( 'admin_enqueue_scripts', array( $admin, 'enqueue_assets' ) );
        add_action( 'admin_menu', array( $admin, 'add_settings_page' ) );
    }

    private function define_public_hooks(): void {
        $public = new My_Plugin_Public( $this->plugin_name, $this->version );
        add_action( 'wp_enqueue_scripts', array( $public, 'enqueue_assets' ) );
        add_shortcode( 'my_plugin', array( $public, 'render_shortcode' ) );
    }

    public function run(): void {
        // Any final bootstrapping.
    }
}

Think of this class as the plugin's control room: it decides which classes exist and wires their methods to WordPress hooks, but it doesn't do the detailed work itself.

The Plugin Lifecycle

A plugin passes through four distinct phases. Knowing which code belongs in each phase is what separates a tidy plugin from one that leaves junk behind.

PhaseWhen it happensTypical work
ActivationUser clicks "Activate"Create tables, set default options, flush rewrite rules
OperationEvery request while activeRegister hooks, render output, process data
DeactivationUser clicks "Deactivate"Clear scheduled events, flush caches β€” but keep data
UninstallationUser deletes the pluginRemove options and tables β€” a full clean-up

Register the activation and deactivation callbacks in your main file. These must be registered before the hooks fire, so keep them at the top level of the plugin.

<?php
// In the main plugin file, after the header.

function my_plugin_activate(): void {
    require_once plugin_dir_path( __FILE__ ) . 'includes/class-activator.php';
    My_Plugin_Activator::activate();
}
register_activation_hook( __FILE__, 'my_plugin_activate' );

function my_plugin_deactivate(): void {
    require_once plugin_dir_path( __FILE__ ) . 'includes/class-deactivator.php';
    My_Plugin_Deactivator::deactivate();
}
register_deactivation_hook( __FILE__, 'my_plugin_deactivate' );

πŸ“– Deactivation vs. uninstall

Deactivation means "pause" β€” the user may switch the plugin back on, so never delete their data here. Uninstall means "remove for good," which is the right place to drop custom tables and options. Uninstall logic goes in a separate uninstall.php (which WordPress runs in isolation) or via register_uninstall_hook().

It's the same courtesy as a restaurant's daily routine: you prep when you open (activation), tidy up when you close for the night (deactivation), and only do the deep clean-out when you're leaving the building for good (uninstall).

Security From Day One

Security isn't a feature you bolt on later β€” it's a habit applied to every piece of data that enters or leaves your plugin. Remember the mantra: sanitize on input, escape on output, and always check permissions and nonces.

Sanitize what comes in

<?php
// Never trust user input.
$raw = isset( $_POST['user_field'] ) ? wp_unslash( $_POST['user_field'] ) : '';

// Sanitize based on the expected data type.
$clean_string = sanitize_text_field( $raw );
$clean_email  = sanitize_email( $raw );
$clean_url    = esc_url_raw( $raw );
$clean_html   = wp_kses_post( $raw ); // Allows a safe subset of HTML.

Validate before saving

<?php
if ( ! is_email( $clean_email ) ) {
    return new WP_Error( 'invalid_email', 'The email provided is invalid.' );
}

Check capabilities and nonces

<?php
// 1. Is this user even allowed to be here?
if ( ! current_user_can( 'manage_options' ) ) {
    wp_die( 'You do not have permission to access this page.' );
}

// 2. Did this request really come from our form? Verify the nonce.
if ( ! isset( $_POST['_wpnonce'] )
    || ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), 'my_plugin_action' ) ) {
    wp_die( 'Security check failed.' );
}

βœ… The three-part rule

  • Sanitize input as it arrives (sanitize_*).
  • Escape data as it goes out (esc_html, esc_attr, esc_url).
  • Authorize every action with a capability check and a nonce.

Data Storage Patterns

WordPress gives you several ways to persist data. Picking the right one keeps your plugin fast and your data queryable.

1. Options API β€” for settings

Best for a small, fixed set of plugin-wide settings.

<?php
update_option( 'my_plugin_settings', array( 'color' => '#3b82f6' ) );
$settings = get_option( 'my_plugin_settings', array() ); // Second arg = default.
delete_option( 'my_plugin_settings' );

2. Custom tables β€” for large, structured data

Use a dedicated table when you have many rows you'll query and index yourself.

<?php
// Run this in your activation callback.
global $wpdb;
$table_name      = $wpdb->prefix . 'my_plugin_data';
$charset_collate = $wpdb->get_charset_collate();

$sql = "CREATE TABLE {$table_name} (
    id       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    created  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    name     VARCHAR(191) NOT NULL,
    body     TEXT NOT NULL,
    PRIMARY KEY  (id)
) {$charset_collate};";

require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql ); // dbDelta creates OR upgrades the table safely.

3. Custom post types β€” for content

Ideal for content that benefits from the editor, revisions, and taxonomies.

<?php
function my_plugin_register_product_cpt(): void {
    register_post_type( 'product', array(
        'labels'      => array( 'name' => 'Products', 'singular_name' => 'Product' ),
        'public'      => true,
        'has_archive' => true,
        'show_in_rest'=> true, // Enables the block editor + REST API.
        'supports'    => array( 'title', 'editor', 'thumbnail' ),
        'menu_icon'   => 'dashicons-cart',
    ) );
}
add_action( 'init', 'my_plugin_register_product_cpt' );

πŸ’‘ Which one do I pick?

Ask "how much data, and how will I query it?" A handful of settings β†’ Options API. Thousands of rows with custom queries β†’ custom table. Editorial content that admins manage in wp-admin β†’ custom post type.

Namespacing & Extensibility

Every plugin shares the same global PHP space, so two plugins that both define a class Admin or a function called process() will crash the site. Prevent collisions with namespaces (modern PHP) or, at minimum, a unique prefix.

<?php
namespace MyPlugin;

class Admin {
    public function register_settings(): void {
        // …
    }
}

// Elsewhere, reference it with its full namespace:
$admin = new \MyPlugin\Admin();

Then make your plugin a good citizen by offering its own hooks so others can extend it β€” exactly the way WordPress lets you extend core.

<?php
namespace MyPlugin;

function process_order( int $order_id ): void {
    do_action( 'myplugin_before_process', $order_id );

    // Let others modify the data mid-flight.
    $data = apply_filters( 'myplugin_order_data', get_order( $order_id ), $order_id );
    save_order( $data );

    do_action( 'myplugin_after_process', $order_id );
}
πŸ’‘ Design for extension. Providing hooks is like designing a car with standardized parts: another developer can swap or enhance a component without rebuilding the whole vehicle β€” and without editing your plugin's files.

πŸ“– Singleton, briefly

Many plugins expose a single instance via a static get_instance() method so the whole codebase shares one object. It's convenient, but use it sparingly β€” an over-used singleton makes automated testing harder because global state is difficult to reset between tests.

Hands-on Exercise

πŸ‹οΈ Scaffold an object-oriented plugin

Objective: Build a real, activatable plugin skeleton with lifecycle handling.

Instructions:

  1. In wp-content/plugins/, create a folder my-first-plugin.
  2. Add my-first-plugin.php with a valid header and the ABSPATH guard.
  3. Create includes/class-my-first-plugin.php holding a main class with a constructor that wires up hooks.
  4. Add includes/class-activator.php with a static activate() method that sets a default option.
  5. Register the activation hook in the main file, then activate the plugin from wp-admin and confirm the option exists.
πŸ’‘ Hint

Use plugin_dir_path( __FILE__ ) to build reliable require_once paths from your main file. In the activator, add_option( 'my_first_plugin_version', '1.0.0' ) is enough to prove activation ran. Check it with a quick get_option() call or by inspecting the wp_options table.

βœ… Example solution

my-first-plugin.php

<?php
/**
 * Plugin Name: My First Plugin
 * Version:     1.0.0
 * Requires PHP: 8.2
 * Text Domain: my-first-plugin
 */
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

require_once plugin_dir_path( __FILE__ ) . 'includes/class-my-first-plugin.php';

function my_first_plugin_activate(): void {
    require_once plugin_dir_path( __FILE__ ) . 'includes/class-activator.php';
    My_First_Plugin_Activator::activate();
}
register_activation_hook( __FILE__, 'my_first_plugin_activate' );

// Boot the plugin.
add_action( 'plugins_loaded', function () {
    ( new My_First_Plugin() )->run();
} );

includes/class-activator.php

<?php
class My_First_Plugin_Activator {
    public static function activate(): void {
        add_option( 'my_first_plugin_version', '1.0.0' );
    }
}

After activating, the my_first_plugin_version row appears in wp_options β€” proof your lifecycle hook fired.

🎯 Quick Quiz

Question 1: What is the single required element that lets WordPress recognize a file as a plugin?

Question 2: During plugin deactivation, what should you generally NOT do?

Question 3: You need to store thousands of log rows that you'll query with custom WHERE clauses. Which storage method fits best?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A plugin adds features without touching core, hooking into WordPress via published APIs.
  • The header comment makes a file a plugin; a folder structure grouped by responsibility keeps it maintainable.
  • Wrap logic in classes with a main orchestrator, and handle the activation / deactivation / uninstall lifecycle deliberately.
  • Apply sanitize β†’ escape β†’ authorize security to every data path from the first commit.
  • Choose storage by shape of data, and namespace plus expose your own hooks so others can extend you.

πŸ“š Further Reading

πŸš€ What's Next?

You've built the skeleton β€” next you'll bring it to life. In Action and Filter Hooks we dive into the event system that lets your plugin do and change things throughout WordPress.

πŸŽ‰ Well done!

You can now scaffold a clean, secure, extensible plugin. Let's wire it into WordPress with hooks.