Plugins
A plugin extends WordPress without touching core. One folder + one PHP file with a header = a plugin. Use hooks (add_action / add_filter) to wire it into the loading lifecycle.
Anatomy, hooks, settings, security
EXAMPLE
<?php
/*
* Plugin Name: My Plugin
* Plugin URI: https://example.com/my-plugin
* Description: Adds a custom feature.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 8.1
* Author: Ada Lovelace
* License: GPL-2.0-or-later
* Text Domain: my-plugin
*/
// 1) Prevent direct access
if (!defined('ABSPATH')) exit;
// 2) Plugin constants
define('MY_PLUGIN_VERSION', '1.0.0');
define('MY_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MY_PLUGIN_URL', plugin_dir_url(__FILE__));
// 3) Activation / deactivation / uninstall hooks
register_activation_hook(__FILE__, 'my_plugin_activate');
register_deactivation_hook(__FILE__, 'my_plugin_deactivate');
register_uninstall_hook(__FILE__, 'my_plugin_uninstall');
function my_plugin_activate() {
// create custom DB tables, options, schedule cron, flush rewrite rules
add_option('my_plugin_options', ['enabled' => true, 'count' => 0]);
flush_rewrite_rules();
}
function my_plugin_deactivate() {
flush_rewrite_rules();
wp_clear_scheduled_hook('my_plugin_cron');
}
function my_plugin_uninstall() {
// remove all plugin data (only when uninstalled via admin UI)
delete_option('my_plugin_options');
// Drop custom tables, etc.
}
// 4) Internationalisation
add_action('init', function () {
load_plugin_textdomain('my-plugin', false, dirname(plugin_basename(__FILE__)) . '/languages');
});
// 5) Enqueue assets — front-end + admin
add_action('wp_enqueue_scripts', function () {
wp_enqueue_style(
'my-plugin-front',
MY_PLUGIN_URL . 'assets/css/front.css',
[],
MY_PLUGIN_VERSION,
);
wp_enqueue_script(
'my-plugin-front',
MY_PLUGIN_URL . 'assets/js/front.js',
[],
MY_PLUGIN_VERSION,
true,
);
// Pass server data to JS
wp_localize_script('my-plugin-front', 'MyPluginData', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('my_plugin'),
]);
});
add_action('admin_enqueue_scripts', function ($hook) {
if ($hook !== 'settings_page_my-plugin') return; // only on our settings page
wp_enqueue_style('my-plugin-admin', MY_PLUGIN_URL . 'assets/css/admin.css', [], MY_PLUGIN_VERSION);
});
// 6) Register a Custom Post Type via plugin
add_action('init', function () {
register_post_type('my_item', [
'public' => true,
'label' => 'My Items',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
'show_in_rest' => true,
'menu_icon' => 'dashicons-cart',
'rewrite' => ['slug' => 'items'],
]);
});
// 7) Shortcode
add_shortcode('my_button', function ($atts) {
$atts = shortcode_atts([
'href' => '#',
'label' => 'Click me',
'style' => 'primary',
], $atts);
return sprintf(
'<a class="my-btn my-btn--%s" href="%s">%s</a>',
esc_attr($atts['style']),
esc_url($atts['href']),
esc_html($atts['label']),
);
});
// 8) Settings page (Settings API)
add_action('admin_menu', function () {
add_options_page(
'My Plugin',
'My Plugin',
'manage_options',
'my-plugin',
'my_plugin_render_settings',
);
});
add_action('admin_init', function () {
register_setting('my_plugin_group', 'my_plugin_options', [
'type' => 'array',
'sanitize_callback' => 'my_plugin_sanitize',
'default' => ['enabled' => true, 'count' => 0],
]);
add_settings_section('main', 'General', null, 'my-plugin');
add_settings_field('enabled', 'Enabled', function () {
$opts = get_option('my_plugin_options');
printf('<input type="checkbox" name="my_plugin_options[enabled]" %s />',
checked(!empty($opts['enabled']), true, false));
}, 'my-plugin', 'main');
});
function my_plugin_sanitize($input) {
return [
'enabled' => !empty($input['enabled']),
'count' => max(0, (int) ($input['count'] ?? 0)),
];
}
function my_plugin_render_settings() {
if (!current_user_can('manage_options')) return;
?>
<div class="wrap">
<h1>My Plugin</h1>
<form method="post" action="options.php">
<?php
settings_fields('my_plugin_group');
do_settings_sections('my-plugin');
submit_button();
?>
</form>
</div>
<?php
}
// 9) REST API endpoint
add_action('rest_api_init', function () {
register_rest_route('my-plugin/v1', '/items', [
'methods' => 'GET',
'callback' => 'my_plugin_get_items',
'permission_callback' => '__return_true',
'args' => [
'count' => [
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => fn($v) => $v >= 1 && $v <= 100,
],
],
]);
});
function my_plugin_get_items(WP_REST_Request $req) {
$query = new WP_Query([
'post_type' => 'my_item',
'posts_per_page' => $req->get_param('count'),
]);
$out = [];
while ($query->have_posts()) {
$query->the_post();
$out[] = [
'id' => get_the_ID(),
'title' => get_the_title(),
'url' => get_permalink(),
];
}
wp_reset_postdata();
return rest_ensure_response($out);
}
// 10) AJAX endpoint (legacy admin-ajax.php)
add_action('wp_ajax_my_plugin_count', 'my_plugin_ajax_count');
add_action('wp_ajax_nopriv_my_plugin_count', 'my_plugin_ajax_count');
function my_plugin_ajax_count() {
check_ajax_referer('my_plugin', 'nonce');
$opts = get_option('my_plugin_options');
$opts['count']++;
update_option('my_plugin_options', $opts);
wp_send_json_success(['count' => $opts['count']]);
}
// 11) Cron
add_action('my_plugin_cron', 'my_plugin_cron_task');
add_action('wp', function () {
if (!wp_next_scheduled('my_plugin_cron')) {
wp_schedule_event(time(), 'daily', 'my_plugin_cron');
}
});
function my_plugin_cron_task() {
// daily work — clean up old data, send digest emails, etc.
}
// 12) Security checklist
// ✅ Check capabilities before privileged actions: current_user_can('manage_options')
// ✅ Use nonces for state-changing forms / AJAX: wp_create_nonce + check_admin_referer
// ✅ Sanitize input: sanitize_text_field, absint, esc_url_raw, wp_kses_post
// ✅ Escape output: esc_html, esc_attr, esc_url, wp_kses_post
// ✅ Use prepared statements: $wpdb->prepare
// ✅ Avoid direct $_POST / $_GET — use the REST request param API or sanitize
// ✅ Validate REST args via sanitize_callback + validate_callback
// ✅ ABSPATH check at top of every file
// ✅ Plugin uninstall: provide uninstall.php to remove options + tables
// 13) Plugin folder structure (recommended)
// my-plugin/
// my-plugin.php (main file with the plugin header)
// uninstall.php
// readme.txt (WordPress.org format)
// readme.md
// LICENSE
// languages/ (translation .pot, .po, .mo)
// assets/
// css/
// js/
// img/
// includes/ (PHP classes / modules)
// class-my-plugin.php
// class-rest.php
// class-admin.php
// templates/ (override-able templates)
// 14) PSR-4 autoload (modern plugins use Composer)
// composer.json:
// {
// "name": "my-vendor/my-plugin",
// "require": { "php": ">=8.1" },
// "autoload": { "psr-4": { "MyPlugin\\\\": "includes/" } }
// }
// Then:
// require_once MY_PLUGIN_DIR . 'vendor/autoload.php';
// use MyPlugin\\Rest;
// (new Rest())->register();
// 15) Plugin best practices
// • Pin Requires at least + Requires PHP — prevents activation on incompatible sites
// • Use prefixed function / class names (my_plugin_*) to avoid collisions
// • Don't fire actions on every request — use cron / events
// • Bail early if not an admin / not the right hook — performance
// • Provide an uninstall.php — cleanly remove data when uninstalled
// • Internationalise from the start — load_plugin_textdomain + __() / _e()
// • Read OTHER plugins' hook docs before overriding their behaviour
// • Auto-update gracefully — Plugin Update Checker for paid plugins; WordPress.org SVN for free
// 16) Distribution
// • Free: WordPress.org plugin repo (SVN-based release; review takes 1-4 weeks)
// • Paid: own site + license check; auto-update via Plugin Update Checker library
// • Internal: a custom updater that fetches from a private endpoint
Why it matters
A plugin is just one PHP file with a comment header — everything else is hooks. Start tiny; add a settings page only when you need configurability; reach for classes + Composer autoload when the plugin grows past a few files.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…