Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 006 / 100

WordPress plugin development: your first plugin explained

A minimal plugin done right.

Expona Admin6 min read

Introduction

WordPress powers over 40% of the web, and most developers eventually need to extend it beyond themes. WordPress plugin development: your first plugin explained shows you how to build a plugin that works reliably in production without cargo-culting code from outdated tutorials. We'll walk through the anatomy of a minimal plugin, the hooks system that powers WordPress, and the patterns that keep your code maintainable as you scale.

By the end of Day 6, you'll understand how plugins hook into WordPress, how to structure your code for reuse, and why those decisions matter when you're supporting code across dozens of sites.

The plugin header: registration through a comment block

Every WordPress plugin begins with a header comment. This is not optional formatting—it's how WordPress discovers and displays your plugin in the admin dashboard. The header lives at the very top of your main plugin file.

php
<?php
/**
 * Plugin Name: Expona Notices
 * Plugin URI: https://expona.dev/plugins/notices
 * Description: Display custom admin notices with persistent storage.
 * Version: 1.0.0
 * Author: Expona Team
 * Author URI: https://expona.dev
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: expona-notices
 * Domain Path: /languages
 */

defined('ABSPATH') || exit;

The PHP opening tag must be on line one, no blank lines before it. WordPress parses this header when it scans your plugins directory. The fields you see above are standard; WordPress displays them on the Plugins admin page. The defined('ABSPATH') || exit; pattern is defensive—it prevents direct access to the file if someone tries to load it outside WordPress.

For a new plugin, you need at minimum Plugin Name and Version. Everything else is metadata, but it's worth filling in. Your team or future self will appreciate the clarity when maintaining multiple plugins across staging and production environments.

Hooks: the extension points that make WordPress flexible

WordPress plugins don't modify core files. Instead, they attach functions to hooks—named events that fire at specific moments during WordPress execution. There are two types: actions (things that happen) and filters (things that are modified).

Actions: execute code at specific times

Actions let you run code when something happens. The most common early hook is admin_init, which fires when the admin area loads.

php
add_action('admin_init', function() {
    // Register settings, enqueue admin scripts, etc.
    register_setting('expona_group', 'expona_notice_color');
});

Another useful action is wp_enqueue_scripts, which fires on both the front-end and admin, ideal for loading stylesheets and JavaScript without race conditions.

php
add_action('wp_enqueue_scripts', function() {
    wp_enqueue_style(
        'expona-notices',
        plugin_dir_url(__FILE__) . 'css/notices.css',
        [],
        '1.0.0'
    );
});

Filters: modify data before it's used

Filters intercept data, modify it, and return it. They're chainable—multiple plugins can filter the same value.

php
add_filter('the_content', function($content) {
    if (is_singular('post')) {
        $notice = get_option('expona_notice_text');
        $content .= '<div class="expona-notice">' . esc_html($notice) . '</div>';
    }
    return $content;
});

Always escape output in filters. Use esc_html() for plain text, wp_kses_post() for HTML markup. Your team will thank you when you're not chasing XSS vulnerabilities in staging.

Choosing the right hook

WordPress fires hundreds of hooks in a specific order. Learning the most common ones saves debugging time. Early hooks like init run before post types are registered. Late hooks like wp_footer run after content is rendered. The WordPress developer handbook documents the execution order—bookmark it.

Activation and deactivation: setup and cleanup

Some plugins need to set up data or options when they're activated, and clean up when they're deactivated. register_activation_hook() and register_deactivation_hook() handle this.

php
register_activation_hook(__FILE__, function() {
    // Create custom tables, set default options, schedule events
    add_option('expona_notice_enabled', true);
    add_option('expona_notice_color', '#333333');
    add_option('expona_notice_text', 'Welcome to Expona');
    
    // Schedule a recurring task
    if (!wp_next_scheduled('expona_daily_cleanup')) {
        wp_schedule_event(time(), 'daily', 'expona_daily_cleanup');
    }
});

register_deactivation_hook(__FILE__, function() {
    // Remove scheduled events, leave options intact for reactivation
    wp_clear_scheduled_hook('expona_daily_cleanup');
});

A critical distinction: deactivation is not deletion. When a user deactivates a plugin, they may reactivate it later and expect their settings to persist. Only delete data if the user explicitly requests it, usually through an admin interface or uninstall hook.

For destructive operations, implement register_uninstall_hook() instead. Create a separate uninstall.php file in your plugin directory if you're deleting database records or options on removal.

php
// uninstall.php (at plugin root, same level as main plugin file)
if (!defined('WP_UNINSTALL_PLUGIN')) {
    exit;
}

delete_option('expona_notice_enabled');
delete_option('expona_notice_color');
delete_option('expona_notice_text');

Structure: classes and autoloading for maintainable code

A single-file plugin works for simple features, but as functionality grows, you need organization. Break your plugin into classes and use autoloading to keep your main file minimal.

Here's a typical directory structure:

expona-notices/ Plugin root
expona-notices.php Main plugin file with header
src/ PHP classes
src/Notice.php Core notice logic
src/Admin.php Admin interface
css/ Stylesheets
js/ JavaScript
uninstall.php Cleanup on deletion

Your main plugin file becomes the bootstrap:

php
<?php
/**
 * Plugin Name: Expona Notices
 * Version: 1.0.0
 * Text Domain: expona-notices
 */

defined('ABSPATH') || exit;

// Autoload classes
require_once dirname(__FILE__) . '/src/Notice.php';
require_once dirname(__FILE__) . '/src/Admin.php';

// Initialize on init hook
add_action('init', function() {
    new \Expona\Notice\Admin();
});

Then organize your classes with namespaces:

php
// src/Notice.php
namespace Expona\Notice;

class Admin {
    public function __construct() {
        add_action('admin_menu', [$this, 'add_menu']);
        add_action('admin_init', [$this, 'register_settings']);
    }

    public function add_menu() {
        add_options_page(
            'Expona Notices',
            'Notices',
            'manage_options',
            'expona-notices',
            [$this, 'render_settings_page']
        );
    }

    public function register_settings() {
        register_setting('expona_group', 'expona_notice_text');
    }

    public function render_settings_page() {
        if (!current_user_can('manage_options')) {
            wp_die('Unauthorized');
        }
        echo '<div class="wrap">
            <h1>' . esc_html(get_admin_page_title()) . '</h1>
            <form method="post" action="options.php">';
        settings_fields('expona_group');
        do_settings_sections('expona-notices');
        submit_button();
        echo '</form></div>';
    }
}

This structure scales. As you add more features—REST endpoints, cron jobs, custom post types—each responsibility lives in its own class. Unit testing becomes feasible because dependencies are injected rather than global.

Common mistakes

Not checking capability before admin actions: Always verify current_user_can() before executing admin functions. Never trust the request alone.

Storing settings without sanitization: WordPress provides sanitize_text_field(), sanitize_email(), and more. Use them when saving options. On retrieval, escape with esc_html(), esc_attr(), or wp_kses_post() depending on context.

Hooking to late WordPress events: If you try to register a custom post type on wp_footer, it won't work. Post types must be registered by the init hook. Study the hook execution order.

Hardcoding paths instead of using helper functions: Use plugin_dir_path(__FILE__), plugin_dir_url(__FILE__), and plugin_basename(__FILE__). They handle trailing slashes and work across different server configurations.

Polluting the global namespace: Without namespaces or prefixes, your function names collide with other plugins. Use a namespace or prefix all functions: expona_get_notice() instead of get_notice().

Key takeaways

  • The plugin header comment is how WordPress discovers and displays your plugin. Include at least Plugin Name and Version; the others are metadata.
  • Actions let you execute code at specific WordPress events; filters let you modify data before it's used. Learn the hook execution order.
  • Use activation hooks for setup (options, schedules, tables) and deactivation for cleanup. Reserve uninstall hooks for destructive operations only.
  • Organize growing plugins into classes with namespaces. Keep your main file as a thin bootstrap that autoloads and initializes your components.
  • Always sanitize input and escape output. Check user capabilities before admin actions. Use WordPress helper functions for paths and URLs, not hardcoded strings.
  • Study the WordPress developer handbook. The hook reference and coding standards are your reference tools—invest time in them early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles