π§© 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.
actions & filters] B --> C[Your Plugin] C --> D[Custom functionality] C --> E[Database interaction] C --> F[Admin interface] C --> G[Front-end output]
The Plugin Header & File Structure
WordPress recognizes a plugin by a special header comment at the top of a PHP file. The bare minimum is a single file with a Plugin Name line β that's genuinely all it takes to appear on the Plugins screen.
<?php
/**
* Plugin Name: My Amazing Plugin
* Plugin URI: https://example.com/my-amazing-plugin
* Description: Adds amazing things to your site.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 8.2
* Author: Your Name
* Author URI: https://yourwebsite.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: my-amazing-plugin
* Domain Path: /languages
*/
// Refuse to run if loaded directly outside of WordPress.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Plugin code goes hereβ¦
β οΈ Always guard against direct access
The if ( ! defined( 'ABSPATH' ) ) { exit; } line stops someone from loading your PHP file directly in a browser and running it out of context. Put it near the top of every PHP file in your plugin.
A one-file plugin is fine for a quick experiment, but real plugins grow. Group files by responsibility so the codebase stays navigable:
my-plugin/
βββ my-plugin.php # Main file: header + bootstrap only
βββ uninstall.php # Cleanup when the plugin is deleted
βββ readme.txt # WordPress.org-format documentation
βββ languages/ # Translation (.pot / .po / .mo) files
βββ includes/ # Core, shared functionality
β βββ class-my-plugin.php # Main orchestrating class
β βββ class-activator.php # Activation logic
β βββ class-deactivator.php # Deactivation logic
β βββ helpers.php # Reusable helper functions
βββ admin/ # Admin-only code, CSS, JS
β βββ class-admin.php
βββ public/ # Front-end code, CSS, JS
β βββ class-public.php
βββ assets/ # Images, icons, static files
This mirrors how an architect organizes building plans β electrical drawings in one folder, plumbing in another. When a bug surfaces in the admin screen, you already know it lives under admin/.
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.
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.
| Phase | When it happens | Typical work |
|---|---|---|
| Activation | User clicks "Activate" | Create tables, set default options, flush rewrite rules |
| Operation | Every request while active | Register hooks, render output, process data |
| Deactivation | User clicks "Deactivate" | Clear scheduled events, flush caches β but keep data |
| Uninstallation | User deletes the plugin | Remove 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:
- In
wp-content/plugins/, create a foldermy-first-plugin. - Add
my-first-plugin.phpwith a valid header and theABSPATHguard. - Create
includes/class-my-first-plugin.phpholding a main class with a constructor that wires up hooks. - Add
includes/class-activator.phpwith a staticactivate()method that sets a default option. - 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.