Master WordPress plugin development - plugin structure, hooks system, actions, filters, custom post types, REST API, settings pages, and security best practices.
Master WordPress plugin development - plugin structure, hooks system, actions, filters, custom post types, REST API, settings pages, and security best practices.
Plugin structure and file organization
WordPress Hooks: Actions and Filters
Working with the WordPress database
Creating custom database tables
Admin menus and settings pages
Settings API and Options API
Shortcodes and widgets in plugins
AJAX in WordPress plugins
REST API integration
Plugin security and sanitization
Internationalization in plugins
Plugin activation, deactivation, and uninstall
Learn about 1. Plugin Structure and Basics.
A WordPress plugin requires only a single PHP file with a properly formatted header comment.
However, professional plugins follow a more structured approach.
Simple Plugin Structure
1my-plugin/
2├── my-plugin.php # Main plugin file (REQUIRED)
3└── readme.txt # Plugin documentation (recommended)A well-organized plugin follows a modular structure that separates concerns and makes
the code maintainable.
Professional Plugin Structure
1my-plugin/
2├── my-plugin.php # Main plugin file with header
3├── uninstall.php # Uninstall cleanup (optional)
4├── readme.txt # WordPress.org readme
5├── LICENSE.txt # License file
6│
7├── includes/ # Core plugin files
8│ ├── class-activator.php # Activation logic
9│ ├── class-deactivator.php # Deactivation logic
10│ ├── class-plugin.php # Main plugin class
11│ └── class-loader.php # Hooks loader
12│
13├── admin/ # Admin-specific functionality
14│ ├── class-admin.php # Admin hooks and functionality
15│ ├── partials/ # Admin view templates
16│ │ └── admin-display.php
17│ ├── css/
18│ │ └── admin.css
19│ └── js/
20│ └── admin.js
21│
22├── public/ # Public-facing functionality
23│ ├── class-public.php # Public hooks and functionality
24│ ├── partials/ # Public view templates
25│ │ └── public-display.php
26│ ├── css/
27│ │ └── public.css
28│ └── js/
29│ └── public.js
30│
31├── languages/ # Translation files
32│ └── my-plugin.pot
33│
34└── assets/ # Additional assets
35 ├── images/
36 ├── fonts/
37 └── icon-256x256.png # Plugin iconEvery WordPress plugin must have a header comment in the main plugin file. This is how
WordPress identifies and displays the plugin in the admin area.
1<?php
2/**
3 * Plugin Name: My Awesome Plugin
4 * Plugin URI: https://example.com/my-awesome-plugin
5 * Description: A comprehensive plugin that demonstrates best practices for WordPress plugin development.
6 * Version: 1.0.0
7 * Requires at least: 6.0
8 * Requires PHP: 8.0
9 * Author: Your Name
10 * Author URI: https://example.com
11 * License: GPL v2 or later
12 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
13 * Text Domain: my-awesome-plugin
14 * Domain Path: /languages
15 * Update URI: https://example.com/my-awesome-plugin/updates
16 */
17
18// Exit if accessed directly
19if (!defined('ABSPATH')) {
20 exit;
21}
22
23// Define plugin constants
24define('MY_PLUGIN_VERSION', '1.0.0');
25define('MY_PLUGIN_PATH', plugin_dir_path(__FILE__));
26define('MY_PLUGIN_URL', plugin_dir_url(__FILE__));
27define('MY_PLUGIN_BASENAME', plugin_basename(__FILE__));
28
29/**
30 * Plugin activation hook
31 */
32function my_plugin_activate() {
33 require_once MY_PLUGIN_PATH . 'includes/class-activator.php';
34 My_Plugin_Activator::activate();
35}
36register_activation_hook(__FILE__, 'my_plugin_activate');
37
38/**
39 * Plugin deactivation hook
40 */
41function my_plugin_deactivate() {
42 require_once MY_PLUGIN_PATH . 'includes/class-deactivator.php';
43 My_Plugin_Deactivator::deactivate();
44}
45register_deactivation_hook(__FILE__, 'my_plugin_deactivate');
46
47/**
48 * Initialize the plugin
49 */
50function my_plugin_init() {
51 require_once MY_PLUGIN_PATH . 'includes/class-plugin.php';
52 $plugin = new My_Plugin();
53 $plugin->run();
54}
55add_action('plugins_loaded', 'my_plugin_init');Learn about 2. WordPress Hooks System.
WordPress hooks are the foundation of plugin development. They allow you to "hook into"
WordPress at specific points to change or add functionality without modifying core files.
Two Types of Hooks
Execute custom code at specific points (e.g., when a post is saved)
Modify data before it's displayed or saved (e.g., change post content)
Actions allow you to execute code at specific points in WordPress execution. Use
add_action()
to hook your function to an action.
1<?php
2/**
3 * Basic Action Example
4 * Hook: Execute code when WordPress initializes
5 */
6function my_plugin_init_function() {
7 // Your code here
8 error_log('My plugin initialized!');
9}
10add_action('init', 'my_plugin_init_function');
11
12/**
13 * Action with Priority and Arguments
14 * Hook: Execute code after a post is saved
15 *
16 * @param int $post_id Post ID
17 * @param WP_Post $post Post object
18 * @param bool $update Whether this is an update
19 */
20function my_plugin_save_post($post_id, $post, $update) {
21 // Skip autosaves
22 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
23 return;
24 }
25
26 // Check user permissions
27 if (!current_user_can('edit_post', $post_id)) {
28 return;
29 }
30
31 // Your custom logic
32 error_log("Post {$post_id} was saved!");
33
34 // Update custom meta
35 update_post_meta($post_id, '_last_modified_by', get_current_user_id());
36}
37// Priority 10 (default), accepts 3 arguments
38add_action('save_post', 'my_plugin_save_post', 10, 3);
39
40/**
41 * Custom Action Hook
42 * Create your own action hook for other plugins/themes to use
43 */
44function my_plugin_process_data($data) {
45 // Process the data
46 $processed_data = strtoupper($data);
47
48 // Fire custom action
49 do_action('my_plugin_data_processed', $processed_data, $data);
50
51 return $processed_data;
52}
53
54/**
55 * Another plugin/theme can hook into your custom action
56 */
57add_action('my_plugin_data_processed', function($processed, $original) {
58 error_log("Original: {$original}, Processed: {$processed}");
59}, 10, 2);Filters allow you to modify data before it's used or displayed. Use
add_filter()
to hook your function to a filter, and always return the modified value.
1<?php
2/**
3 * Basic Filter Example
4 * Modify the post content
5 */
6function my_plugin_modify_content($content) {
7 // Add signature to the end of post content
8 if (is_single()) {
9 $content .= '<p class="signature">Written by ' . get_the_author() . '</p>';
10 }
11 return $content; // IMPORTANT: Always return the content!
12}
13add_filter('the_content', 'my_plugin_modify_content');
14
15/**
16 * Filter with Priority and Arguments
17 * Modify the post title
18 */
19function my_plugin_modify_title($title, $post_id) {
20 // Add prefix to drafts
21 $post = get_post($post_id);
22 if ($post && $post->post_status === 'draft') {
23 $title = '[DRAFT] ' . $title;
24 }
25 return $title;
26}
27add_filter('the_title', 'my_plugin_modify_title', 10, 2);
28
29/**
30 * Custom Filter Hook
31 * Create your own filter for other plugins/themes to use
32 */
33function my_plugin_get_greeting($name) {
34 $greeting = "Hello, {$name}!";
35
36 // Apply custom filter
37 $greeting = apply_filters('my_plugin_greeting', $greeting, $name);
38
39 return $greeting;
40}
41
42/**
43 * Another plugin/theme can modify the greeting
44 */
45add_filter('my_plugin_greeting', function($greeting, $name) {
46 // Make it more formal
47 return "Good day, {$name}!";
48}, 10, 2);
49
50/**
51 * Conditional Filter
52 * Apply filter only in certain conditions
53 */
54function my_plugin_conditional_filter($content) {
55 // Only apply on posts, not pages
56 if (is_singular('post')) {
57 $content = '<div class="post-wrapper">' . $content . '</div>';
58 }
59 return $content;
60}
61add_filter('the_content', 'my_plugin_conditional_filter', 20);Essential Action Hooks
wp_enqueue_scripts
admin_enqueue_scripts
widgets_init
wp_ajax_[action]
wp_ajax_nopriv_[action]
Essential Filter Hooks
the_content
the_excerpt
excerpt_length
excerpt_more
Learn about 3. Working with the Database.
WordPress provides the
global object for database operations.
Always use
instead of raw MySQL functions for security and compatibility.
1<?php
2/**
3 * Using $wpdb for database operations
4 */
5global $wpdb;
6
7/**
8 * SELECT Query - Get single row
9 */
10$user_id = 1;
11$user = $wpdb->get_row(
12 $wpdb->prepare(
13 "SELECT * FROM {$wpdb->users} WHERE ID = %d",
14 $user_id
15 )
16);
17
18if ($user) {
19 echo "User: " . $user->user_login;
20}
21
22/**
23 * SELECT Query - Get multiple rows
24 */
25$results = $wpdb->get_results(
26 "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' LIMIT 10"
27);
28
29foreach ($results as $post) {
30 echo $post->post_title . '<br>';
31}
32
33/**
34 * SELECT Query - Get single variable
35 */
36$post_count = $wpdb->get_var(
37 "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish'"
38);
39echo "Total published posts: {$post_count}";
40
41/**
42 * SELECT Query - Get single column
43 */
44$post_ids = $wpdb->get_col(
45 "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'post' LIMIT 5"
46);
47// Result: array(1, 2, 3, 4, 5)
48
49/**
50 * INSERT Query
51 */
52$inserted = $wpdb->insert(
53 $wpdb->prefix . 'my_custom_table',
54 array(
55 'name' => 'John Doe',
56 'email' => '[email protected]',
57 'created_at' => current_time('mysql'),
58 ),
59 array('%s', '%s', '%s') // Data format: %s = string, %d = integer, %f = float
60);
61
62if ($inserted) {
63 $insert_id = $wpdb->insert_id;
64 echo "Inserted row ID: {$insert_id}";
65}
66
67/**
68 * UPDATE Query
69 */
70$updated = $wpdb->update(
71 $wpdb->prefix . 'my_custom_table',
72 array('name' => 'Jane Doe'), // Data to update
73 array('ID' => 5), // WHERE clause
74 array('%s'), // Data format
75 array('%d') // WHERE format
76);
77
78echo "Rows updated: {$updated}";
79
80/**
81 * DELETE Query
82 */
83$deleted = $wpdb->delete(
84 $wpdb->prefix . 'my_custom_table',
85 array('ID' => 5), // WHERE clause
86 array('%d') // WHERE format
87);
88
89echo "Rows deleted: {$deleted}";
90
91/**
92 * Complex query with $wpdb->prepare() for security
93 */
94$post_type = 'post';
95$status = 'publish';
96$limit = 10;
97
98$results = $wpdb->get_results(
99 $wpdb->prepare(
100 "SELECT p.ID, p.post_title, pm.meta_value as views
101 FROM {$wpdb->posts} p
102 LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = 'post_views'
103 WHERE p.post_type = %s AND p.post_status = %s
104 ORDER BY CAST(pm.meta_value AS UNSIGNED) DESC
105 LIMIT %d",
106 $post_type,
107 $status,
108 $limit
109 )
110);For complex data structures, create custom database tables. Always use the activation
hook to create tables.
1<?php
2/**
3 * Create custom database table on plugin activation
4 */
5function my_plugin_create_tables() {
6 global $wpdb;
7
8 $table_name = $wpdb->prefix . 'my_custom_table';
9 $charset_collate = $wpdb->get_charset_collate();
10
11 $sql = "CREATE TABLE {$table_name} (
12 id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
13 user_id bigint(20) UNSIGNED NOT NULL,
14 name varchar(255) NOT NULL,
15 email varchar(255) NOT NULL,
16 status varchar(20) DEFAULT 'active',
17 metadata longtext,
18 created_at datetime NOT NULL,
19 updated_at datetime NOT NULL,
20 PRIMARY KEY (id),
21 KEY user_id (user_id),
22 KEY status (status),
23 KEY created_at (created_at)
24 ) {$charset_collate};";
25
26 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
27 dbDelta($sql);
28
29 // Store database version for future upgrades
30 add_option('my_plugin_db_version', '1.0');
31}
32register_activation_hook(__FILE__, 'my_plugin_create_tables');
33
34/**
35 * Database CRUD operations for custom table
36 */
37class My_Plugin_DB {
38
39 private $table_name;
40
41 public function __construct() {
42 global $wpdb;
43 $this->table_name = $wpdb->prefix . 'my_custom_table';
44 }
45
46 /**
47 * Create a new record
48 */
49 public function create($data) {
50 global $wpdb;
51
52 $defaults = array(
53 'user_id' => get_current_user_id(),
54 'created_at' => current_time('mysql'),
55 'updated_at' => current_time('mysql'),
56 );
57
58 $data = wp_parse_args($data, $defaults);
59
60 $inserted = $wpdb->insert(
61 $this->table_name,
62 $data,
63 array('%d', '%s', '%s', '%s', '%s', '%s', '%s')
64 );
65
66 return $inserted ? $wpdb->insert_id : false;
67 }
68
69 /**
70 * Get a single record by ID
71 */
72 public function get($id) {
73 global $wpdb;
74
75 return $wpdb->get_row(
76 $wpdb->prepare(
77 "SELECT * FROM {$this->table_name} WHERE id = %d",
78 $id
79 )
80 );
81 }
82
83 /**
84 * Get all records with optional filters
85 */
86 public function get_all($args = array()) {
87 global $wpdb;
88
89 $defaults = array(
90 'status' => 'active',
91 'user_id' => 0,
92 'orderby' => 'created_at',
93 'order' => 'DESC',
94 'limit' => 10,
95 'offset' => 0,
96 );
97
98 $args = wp_parse_args($args, $defaults);
99
100 $where = array();
101 $where_values = array();
102
103 if ($args['status']) {
104 $where[] = 'status = %s';
105 $where_values[] = $args['status'];
106 }
107
108 if ($args['user_id']) {
109 $where[] = 'user_id = %d';
110 $where_values[] = $args['user_id'];
111 }
112
113 $where_clause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
114
115 $query = "SELECT * FROM {$this->table_name} {$where_clause}
116 ORDER BY {$args['orderby']} {$args['order']}
117 LIMIT %d OFFSET %d";
118
119 $where_values[] = $args['limit'];
120 $where_values[] = $args['offset'];
121
122 return $wpdb->get_results(
123 $wpdb->prepare($query, $where_values)
124 );
125 }
126
127 /**
128 * Update a record
129 */
130 public function update($id, $data) {
131 global $wpdb;
132
133 $data['updated_at'] = current_time('mysql');
134
135 return $wpdb->update(
136 $this->table_name,
137 $data,
138 array('id' => $id),
139 array('%s', '%s', '%s', '%s', '%s'),
140 array('%d')
141 );
142 }
143
144 /**
145 * Delete a record
146 */
147 public function delete($id) {
148 global $wpdb;
149
150 return $wpdb->delete(
151 $this->table_name,
152 array('id' => $id),
153 array('%d')
154 );
155 }
156
157 /**
158 * Get total count
159 */
160 public function count($args = array()) {
161 global $wpdb;
162
163 $where = array();
164 $where_values = array();
165
166 if (!empty($args['status'])) {
167 $where[] = 'status = %s';
168 $where_values[] = $args['status'];
169 }
170
171 if (!empty($args['user_id'])) {
172 $where[] = 'user_id = %d';
173 $where_values[] = $args['user_id'];
174 }
175
176 $where_clause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
177
178 $query = "SELECT COUNT(*) FROM {$this->table_name} {$where_clause}";
179
180 if (!empty($where_values)) {
181 return $wpdb->get_var($wpdb->prepare($query, $where_values));
182 }
183
184 return $wpdb->get_var($query);
185 }
186}Learn about 4. Admin Menus and Settings Pages.
Learn about 4.1 Adding Admin Menu Pages.
1<?php
2/**
3 * Add admin menu pages
4 */
5function my_plugin_admin_menu() {
6 // Add top-level menu page
7 add_menu_page(
8 'My Plugin Settings', // Page title
9 'My Plugin', // Menu title
10 'manage_options', // Capability required
11 'my-plugin-settings', // Menu slug
12 'my_plugin_settings_page', // Callback function
13 'dashicons-admin-generic', // Icon (dashicon class or URL)
14 30 // Position (after Comments)
15 );
16
17 // Add submenu page
18 add_submenu_page(
19 'my-plugin-settings', // Parent slug
20 'Advanced Settings', // Page title
21 'Advanced', // Menu title
22 'manage_options', // Capability
23 'my-plugin-advanced', // Menu slug
24 'my_plugin_advanced_page' // Callback function
25 );
26
27 // Add settings page under Settings menu
28 add_options_page(
29 'My Plugin Options', // Page title
30 'My Plugin', // Menu title
31 'manage_options', // Capability
32 'my-plugin-options', // Menu slug
33 'my_plugin_options_page' // Callback function
34 );
35}
36add_action('admin_menu', 'my_plugin_admin_menu');
37
38/**
39 * Settings page callback
40 */
41function my_plugin_settings_page() {
42 // Check user capabilities
43 if (!current_user_can('manage_options')) {
44 wp_die(__('You do not have sufficient permissions to access this page.'));
45 }
46
47 ?>
48 <div class="wrap">
49 <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
50
51 <form action="options.php" method="post">
52 <?php
53 // Output security fields
54 settings_fields('my_plugin_settings');
55
56 // Output setting sections and fields
57 do_settings_sections('my-plugin-settings');
58
59 // Output save button
60 submit_button('Save Settings');
61 ?>
62 </form>
63 </div>
64 <?php
65}The Settings API provides a secure way to handle plugin settings with automatic
sanitization and nonce verification.
1<?php
2/**
3 * Register plugin settings
4 */
5function my_plugin_register_settings() {
6 // Register a new setting
7 register_setting(
8 'my_plugin_settings', // Option group
9 'my_plugin_options', // Option name
10 array(
11 'type' => 'array',
12 'sanitize_callback' => 'my_plugin_sanitize_options',
13 'default' => array(),
14 )
15 );
16
17 // Add a settings section
18 add_settings_section(
19 'my_plugin_general_section', // Section ID
20 'General Settings', // Title
21 'my_plugin_general_section_cb', // Callback
22 'my-plugin-settings' // Page slug
23 );
24
25 // Add settings fields
26 add_settings_field(
27 'enable_feature', // Field ID
28 'Enable Feature', // Title
29 'my_plugin_enable_field_cb', // Callback
30 'my-plugin-settings', // Page slug
31 'my_plugin_general_section', // Section ID
32 array('label_for' => 'enable_feature')
33 );
34
35 add_settings_field(
36 'api_key',
37 'API Key',
38 'my_plugin_api_key_field_cb',
39 'my-plugin-settings',
40 'my_plugin_general_section',
41 array('label_for' => 'api_key')
42 );
43
44 add_settings_field(
45 'items_per_page',
46 'Items Per Page',
47 'my_plugin_items_per_page_field_cb',
48 'my-plugin-settings',
49 'my_plugin_general_section',
50 array('label_for' => 'items_per_page')
51 );
52}
53add_action('admin_init', 'my_plugin_register_settings');
54
55/**
56 * Section callback
57 */
58function my_plugin_general_section_cb($args) {
59 ?>
60 <p>Configure the general settings for the plugin.</p>
61 <?php
62}
63
64/**
65 * Field callbacks
66 */
67function my_plugin_enable_field_cb($args) {
68 $options = get_option('my_plugin_options', array());
69 $value = isset($options['enable_feature']) ? $options['enable_feature'] : false;
70 ?>
71 <input type="checkbox"
72 id="<?php echo esc_attr($args['label_for']); ?>"
73 name="my_plugin_options[enable_feature]"
74 value="1"
75 <?php checked($value, 1); ?>>
76 <label for="<?php echo esc_attr($args['label_for']); ?>">
77 Enable this awesome feature
78 </label>
79 <?php
80}
81
82function my_plugin_api_key_field_cb($args) {
83 $options = get_option('my_plugin_options', array());
84 $value = isset($options['api_key']) ? $options['api_key'] : '';
85 ?>
86 <input type="text"
87 id="<?php echo esc_attr($args['label_for']); ?>"
88 name="my_plugin_options[api_key]"
89 value="<?php echo esc_attr($value); ?>"
90 class="regular-text">
91 <p class="description">Enter your API key from the service provider.</p>
92 <?php
93}
94
95function my_plugin_items_per_page_field_cb($args) {
96 $options = get_option('my_plugin_options', array());
97 $value = isset($options['items_per_page']) ? $options['items_per_page'] : 10;
98 ?>
99 <input type="number"
100 id="<?php echo esc_attr($args['label_for']); ?>"
101 name="my_plugin_options[items_per_page]"
102 value="<?php echo esc_attr($value); ?>"
103 min="1"
104 max="100"
105 class="small-text">
106 <?php
107}
108
109/**
110 * Sanitize settings
111 */
112function my_plugin_sanitize_options($input) {
113 $sanitized = array();
114
115 if (isset($input['enable_feature'])) {
116 $sanitized['enable_feature'] = (bool) $input['enable_feature'];
117 }
118
119 if (isset($input['api_key'])) {
120 $sanitized['api_key'] = sanitize_text_field($input['api_key']);
121 }
122
123 if (isset($input['items_per_page'])) {
124 $sanitized['items_per_page'] = absint($input['items_per_page']);
125 if ($sanitized['items_per_page'] < 1) {
126 $sanitized['items_per_page'] = 10;
127 }
128 }
129
130 return $sanitized;
131}Learn about 5. Shortcodes.
Shortcodes allow users to embed plugin functionality in posts, pages, and widgets
using simple tags like
[my_shortcode]
1<?php
2/**
3 * Basic shortcode
4 * Usage: [hello_world]
5 */
6function my_plugin_hello_world_shortcode($atts) {
7 return '<p>Hello, World!</p>';
8}
9add_shortcode('hello_world', 'my_plugin_hello_world_shortcode');
10
11/**
12 * Shortcode with attributes
13 * Usage: [greeting name="John" time="morning"]
14 */
15function my_plugin_greeting_shortcode($atts) {
16 // Set default attributes
17 $atts = shortcode_atts(array(
18 'name' => 'Friend',
19 'time' => 'day',
20 ), $atts, 'greeting');
21
22 // Sanitize attributes
23 $name = sanitize_text_field($atts['name']);
24 $time = sanitize_text_field($atts['time']);
25
26 // Generate output
27 $greetings = array(
28 'morning' => 'Good morning',
29 'afternoon' => 'Good afternoon',
30 'evening' => 'Good evening',
31 'day' => 'Good day',
32 );
33
34 $greeting = isset($greetings[$time]) ? $greetings[$time] : $greetings['day'];
35
36 return '<p class="greeting">' . esc_html($greeting . ', ' . $name) . '!</p>';
37}
38add_shortcode('greeting', 'my_plugin_greeting_shortcode');
39
40/**
41 * Shortcode with content
42 * Usage: [box color="blue"]Content here[/box]
43 */
44function my_plugin_box_shortcode($atts, $content = null) {
45 $atts = shortcode_atts(array(
46 'color' => 'gray',
47 'style' => 'default',
48 ), $atts, 'box');
49
50 $color = sanitize_html_class($atts['color']);
51 $style = sanitize_html_class($atts['style']);
52
53 $output = '<div class="my-plugin-box box-' . $color . ' box-style-' . $style . '">';
54 $output .= do_shortcode($content); // Allow nested shortcodes
55 $output .= '</div>';
56
57 return $output;
58}
59add_shortcode('box', 'my_plugin_box_shortcode');
60
61/**
62 * Complex shortcode with database query
63 * Usage: [recent_posts count="5" category="news"]
64 */
65function my_plugin_recent_posts_shortcode($atts) {
66 $atts = shortcode_atts(array(
67 'count' => 5,
68 'category' => '',
69 'orderby' => 'date',
70 ), $atts, 'recent_posts');
71
72 $args = array(
73 'posts_per_page' => absint($atts['count']),
74 'orderby' => sanitize_text_field($atts['orderby']),
75 'order' => 'DESC',
76 );
77
78 if (!empty($atts['category'])) {
79 $args['category_name'] = sanitize_text_field($atts['category']);
80 }
81
82 $query = new WP_Query($args);
83
84 // Start output buffering
85 ob_start();
86
87 if ($query->have_posts()) :
88 ?>
89 <div class="my-plugin-recent-posts">
90 <ul>
91 <?php while ($query->have_posts()) : $query->the_post(); ?>
92 <li>
93 <a href="<?php the_permalink(); ?>">
94 <?php the_title(); ?>
95 </a>
96 <span class="date"><?php echo get_the_date(); ?></span>
97 </li>
98 <?php endwhile; ?>
99 </ul>
100 </div>
101 <?php
102 wp_reset_postdata();
103 else :
104 ?>
105 <p>No posts found.</p>
106 <?php
107 endif;
108
109 // Return the buffered output
110 return ob_get_clean();
111}Learn about 6. AJAX in WordPress Plugins.
Learn about 6.1 AJAX Handler Setup.
1<?php
2/**
3 * Enqueue scripts with AJAX support
4 */
5function my_plugin_enqueue_ajax_scripts() {
6 wp_enqueue_script(
7 'my-plugin-ajax',
8 plugins_url('js/ajax.js', __FILE__),
9 array('jquery'),
10 '1.0.0',
11 true
12 );
13
14 // Localize script with AJAX URL and nonce
15 wp_localize_script('my-plugin-ajax', 'myPluginAjax', array(
16 'ajaxUrl' => admin_url('admin-ajax.php'),
17 'nonce' => wp_create_nonce('my_plugin_ajax_nonce'),
18 ));
19}
20add_action('wp_enqueue_scripts', 'my_plugin_enqueue_ajax_scripts');
21
22/**
23 * AJAX handler for logged-in users
24 */
25function my_plugin_ajax_handler() {
26 // Verify nonce
27 check_ajax_referer('my_plugin_ajax_nonce', 'nonce');
28
29 // Check user permissions
30 if (!current_user_can('read')) {
31 wp_send_json_error(array(
32 'message' => 'Insufficient permissions',
33 ), 403);
34 }
35
36 // Get and sanitize input
37 $action_type = isset($_POST['action_type']) ? sanitize_text_field($_POST['action_type']) : '';
38 $item_id = isset($_POST['item_id']) ? absint($_POST['item_id']) : 0;
39
40 // Validate input
41 if (empty($action_type) || !$item_id) {
42 wp_send_json_error(array(
43 'message' => 'Invalid parameters',
44 ), 400);
45 }
46
47 // Process the request
48 switch ($action_type) {
49 case 'like':
50 $result = my_plugin_process_like($item_id);
51 break;
52
53 case 'save':
54 $result = my_plugin_process_save($item_id);
55 break;
56
57 default:
58 wp_send_json_error(array(
59 'message' => 'Unknown action type',
60 ), 400);
61 }
62
63 // Return success response
64 if ($result) {
65 wp_send_json_success(array(
66 'message' => 'Action completed successfully',
67 'data' => $result,
68 ));
69 } else {
70 wp_send_json_error(array(
71 'message' => 'Action failed',
72 ), 500);
73 }
74}
75add_action('wp_ajax_my_plugin_action', 'my_plugin_ajax_handler');
76
77/**
78 * AJAX handler for non-logged-in users
79 */
80function my_plugin_ajax_public_handler() {
81 // Verify nonce
82 check_ajax_referer('my_plugin_ajax_nonce', 'nonce');
83
84 // Process public data
85 $data = isset($_POST['data']) ? sanitize_text_field($_POST['data']) : '';
86
87 wp_send_json_success(array(
88 'message' => 'Public action completed',
89 'data' => $data,
90 ));
91}
92add_action('wp_ajax_nopriv_my_plugin_public_action', 'my_plugin_ajax_public_handler');Learn about 6.2 JavaScript AJAX Implementation.
1// js/ajax.js
2
3jQuery(document).ready(function($) {
4 'use strict';
5
6 /**
7 * Handle button click
8 */
9 $('.my-plugin-like-btn').on('click', function(e) {
10 e.preventDefault();
11
12 var button = $(this);
13 var itemId = button.data('item-id');
14
15 // Disable button during request
16 button.prop('disabled', true);
17
18 $.ajax({
19 url: myPluginAjax.ajaxUrl,
20 type: 'POST',
21 data: {
22 action: 'my_plugin_action',
23 action_type: 'like',
24 item_id: itemId,
25 nonce: myPluginAjax.nonce
26 },
27 success: function(response) {
28 if (response.success) {
29 button.text('Liked!');
30 button.addClass('liked');
31
32 // Update count
33 var count = response.data.like_count || 0;
34 button.siblings('.like-count').text(count);
35 } else {
36 alert('Error: ' + response.data.message);
37 }
38 },
39 error: function(xhr, status, error) {
40 alert('AJAX error: ' + error);
41 },
42 complete: function() {
43 // Re-enable button
44 button.prop('disabled', false);
45 }
46 });
47 });
48});Learn about 7. REST API Integration.
Learn about 7.1 Registering Custom REST API Endpoints.
1<?php
2/**
3 * Register custom REST API routes
4 */
5function my_plugin_register_rest_routes() {
6 // Register a GET endpoint
7 register_rest_route('my-plugin/v1', '/items', array(
8 'methods' => 'GET',
9 'callback' => 'my_plugin_get_items',
10 'permission_callback' => '__return_true', // Public endpoint
11 ));
12
13 // Register a GET endpoint with ID parameter
14 register_rest_route('my-plugin/v1', '/items/(?P<id>\\d+)', array(
15 'methods' => 'GET',
16 'callback' => 'my_plugin_get_item',
17 'permission_callback' => '__return_true',
18 'args' => array(
19 'id' => array(
20 'validate_callback' => function($param) {
21 return is_numeric($param);
22 },
23 ),
24 ),
25 ));
26
27 // Register a POST endpoint (requires authentication)
28 register_rest_route('my-plugin/v1', '/items', array(
29 'methods' => 'POST',
30 'callback' => 'my_plugin_create_item',
31 'permission_callback' => function() {
32 return current_user_can('edit_posts');
33 },
34 'args' => array(
35 'title' => array(
36 'required' => true,
37 'validate_callback' => function($param) {
38 return is_string($param) && !empty($param);
39 },
40 'sanitize_callback' => 'sanitize_text_field',
41 ),
42 'content' => array(
43 'required' => true,
44 'sanitize_callback' => 'sanitize_textarea_field',
45 ),
46 ),
47 ));
48
49 // Register a DELETE endpoint
50 register_rest_route('my-plugin/v1', '/items/(?P<id>\\d+)', array(
51 'methods' => 'DELETE',
52 'callback' => 'my_plugin_delete_item',
53 'permission_callback' => function() {
54 return current_user_can('delete_posts');
55 },
56 'args' => array(
57 'id' => array(
58 'validate_callback' => function($param) {
59 return is_numeric($param);
60 },
61 ),
62 ),
63 ));
64}
65add_action('rest_api_init', 'my_plugin_register_rest_routes');
66
67/**
68 * REST API callbacks
69 */
70function my_plugin_get_items(WP_REST_Request $request) {
71 // Get query parameters
72 $per_page = $request->get_param('per_page') ?: 10;
73 $page = $request->get_param('page') ?: 1;
74
75 $db = new My_Plugin_DB();
76 $items = $db->get_all(array(
77 'limit' => absint($per_page),
78 'offset' => absint(($page - 1) * $per_page),
79 ));
80
81 $total = $db->count();
82
83 $response = new WP_REST_Response($items);
84 $response->header('X-Total-Count', $total);
85 $response->header('X-Total-Pages', ceil($total / $per_page));
86
87 return $response;
88}
89
90function my_plugin_get_item(WP_REST_Request $request) {
91 $id = $request->get_param('id');
92 $db = new My_Plugin_DB();
93 $item = $db->get($id);
94
95 if (!$item) {
96 return new WP_Error(
97 'not_found',
98 'Item not found',
99 array('status' => 404)
100 );
101 }
102
103 return new WP_REST_Response($item, 200);
104}
105
106function my_plugin_create_item(WP_REST_Request $request) {
107 $db = new My_Plugin_DB();
108
109 $data = array(
110 'name' => $request->get_param('title'),
111 'email' => $request->get_param('content'),
112 'user_id' => get_current_user_id(),
113 );
114
115 $id = $db->create($data);
116
117 if (!$id) {
118 return new WP_Error(
119 'creation_failed',
120 'Failed to create item',
121 array('status' => 500)
122 );
123 }
124
125 $item = $db->get($id);
126
127 return new WP_REST_Response($item, 201);
128}
129
130function my_plugin_delete_item(WP_REST_Request $request) {
131 $id = $request->get_param('id');
132 $db = new My_Plugin_DB();
133
134 $deleted = $db->delete($id);
135
136 if (!$deleted) {
137 return new WP_Error(
138 'deletion_failed',
139 'Failed to delete item',
140 array('status' => 500)
141 );
142 }
143
144 return new WP_REST_Response(array(
145 'deleted' => true,
146 'id' => $id,
147 ), 200);
148}Learn about 8. Plugin Security Best Practices.
1// Sanitization functions
2$text = sanitize_text_field($_POST['input']);
3$email = sanitize_email($_POST['email']);
4$url = esc_url_raw($_POST['url']);
5$textarea = sanitize_textarea_field($_POST['content']);
6$html = wp_kses_post($_POST['html_content']);
7$int = absint($_POST['number']);
8
9// Validation
10if (!is_email($email)) {
11 wp_die('Invalid email address');
12}
13
14if (filter_var($url, FILTER_VALIDATE_URL) === false) {
15 wp_die('Invalid URL');
16}1// Escape functions
2echo esc_html($text); // Escape HTML
3echo esc_attr($attribute); // Escape HTML attributes
4echo esc_url($url); // Escape URLs
5echo esc_js($javascript); // Escape JavaScript
6echo wp_kses_post($html_content); // Allow safe HTML tags1// Generate nonce in form
2wp_nonce_field('my_plugin_action', 'my_plugin_nonce');
3
4// Verify nonce on submission
5if (!wp_verify_nonce($_POST['my_plugin_nonce'], 'my_plugin_action')) {
6 wp_die('Security check failed');
7}1if (!current_user_can('manage_options')) {
2 wp_die('You do not have permission to perform this action');
3}1// WRONG - Vulnerable to SQL injection
2$wpdb->query("SELECT * FROM table WHERE id = {$_GET['id']}");
3
4// CORRECT - Use $wpdb->prepare()
5$wpdb->query($wpdb->prepare(
6 "SELECT * FROM table WHERE id = %d",
7 $_GET['id']
8));1// Add to top of every PHP file
2if (!defined('ABSPATH')) {
3 exit; // Exit if accessed directly
4}Learn about 9. Plugin Activation, Deactivation, and Uninstall.
1<?php
2/**
3 * Activation Hook
4 * Runs when plugin is activated
5 */
6function my_plugin_activate() {
7 // Create custom database tables
8 my_plugin_create_tables();
9
10 // Set default options
11 add_option('my_plugin_version', '1.0.0');
12 add_option('my_plugin_options', array(
13 'enable_feature' => true,
14 'items_per_page' => 10,
15 ));
16
17 // Create custom role
18 add_role('my_plugin_user', 'Plugin User', array(
19 'read' => true,
20 ));
21
22 // Schedule cron event
23 if (!wp_next_scheduled('my_plugin_daily_task')) {
24 wp_schedule_event(time(), 'daily', 'my_plugin_daily_task');
25 }
26
27 // Flush rewrite rules
28 flush_rewrite_rules();
29}
30register_activation_hook(__FILE__, 'my_plugin_activate');
31
32/**
33 * Deactivation Hook
34 * Runs when plugin is deactivated
35 */
36function my_plugin_deactivate() {
37 // Clear scheduled cron events
38 $timestamp = wp_next_scheduled('my_plugin_daily_task');
39 if ($timestamp) {
40 wp_unschedule_event($timestamp, 'my_plugin_daily_task');
41 }
42
43 // Flush rewrite rules
44 flush_rewrite_rules();
45
46 // NOTE: Do NOT delete data on deactivation
47 // Users may want to reactivate the plugin
48}
49register_deactivation_hook(__FILE__, 'my_plugin_deactivate');
50
51/**
52 * Uninstall Hook (uninstall.php)
53 * Runs when plugin is deleted via admin
54 */
55// uninstall.php
56if (!defined('WP_UNINSTALL_PLUGIN')) {
57 exit;
58}
59
60// Delete options
61delete_option('my_plugin_version');
62delete_option('my_plugin_options');
63
64// Delete custom database tables
65global $wpdb;
66$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}my_custom_table");
67
68// Remove custom role
69remove_role('my_plugin_user');
70
71// Delete all post meta
72delete_post_meta_by_key('_my_plugin_meta_key');
73
74// Delete transients
75delete_transient('my_plugin_cache');Learn about 10. Best Practices.
Use proper file structure:
Separate admin, public, and core functionality
Follow OOP principles:
Use classes for better organization and reusability
Use namespaces:
Avoid naming conflicts (PHP 5.3+)
Prefix everything:
Functions, classes, globals with unique prefix
Autoload classes:
Use PSR-4 autoloading or WordPress autoloader
Minimize database queries:
Use WP_Query efficiently, cache results
Use transients:
Cache expensive operations with set_transient()
Load scripts conditionally:
Only where needed, not site-wide
Optimize queries:
Use proper indexes on custom tables
Lazy load:
Don't load all features if not used on current page
Never trust user input:
Always sanitize and validate
Escape all output:
Use esc_html(), esc_attr(), esc_url()
Use nonces:
Verify all form submissions and AJAX requests
Check capabilities:
Verify user permissions before actions
Prepare SQL queries:
Always use $wpdb->prepare()
Use WordPress functions:
Don't reinvent the wheel
Follow WordPress Coding Standards:
Use PHPCS with WordPress rules
Document your code:
PHPDoc for all functions and classes
Use meaningful names:
Clear, descriptive variable and function names
Handle errors gracefully:
Use WP_Error, try-catch blocks
Write testable code:
Unit tests with PHPUnit
Test with different themes:
Ensure theme independence
Check PHP versions:
Support minimum PHP 7.4+
Test with other plugins:
Avoid conflicts
Don't override core:
Use hooks instead of replacing core functions
Multisite compatible:
If applicable, support WordPress multisite
Make all strings translatable:
Use __(), _e(), esc_html__()
Use text domain consistently:
Match plugin slug
Load text domain:
In init or plugins_loaded hook
Generate .pot file:
For translators
No phone-home:
Don't include external service calls without disclosure
No obfuscated code:
All code must be readable
GPL compatible:
Use GPL v2 or later license
Include readme.txt:
Following WordPress.org format
Respect trademarks:
Don't use protected names
Provide clear documentation:
Help users understand your plugin
Intuitive admin interface:
Follow WordPress UI patterns
Helpful error messages:
Guide users to fix issues
Settings export/import:
Allow backup of configuration
Uninstall cleanly:
Remove all data when requested
Learn about 11. Exercises.
Create a basic WordPress plugin with the following features:
Plugin header with proper metadata
Add a custom admin menu page under Settings
Create a settings page with one checkbox option (saved to database)
Create a shortcode
[current_year]
that displays the current year
Add a filter to modify post content (add "Last updated: [date]" at the end)
Proper sanitization and escaping
Activation hook that sets default options
Success Criteria:
Plugin activates without errors, shortcode works, settings save correctly
Build a contact form submissions plugin with custom database table:
Create custom database table on activation (id, name, email, message, created_at)
Create a shortcode
[contact_form]
that displays a contact form
Handle form submission with AJAX (nonce verification, sanitization)
Store submissions in custom database table
Create admin page to view all submissions in a table
Add pagination to submissions table (10 per page)
Add ability to delete individual submissions
Display success/error messages to users
Clean uninstall (remove table and options)
Success Criteria:
Form submits via AJAX, data saves to database, admin can view/delete submissions
Build a full-featured bookmark manager plugin:
Custom table for bookmarks (id, user_id, title, url, description, category, created_at)
Proper indexes for performance
Admin Interface:
Custom admin menu with submenu pages
List all bookmarks with search and filter by category
Add/edit bookmark form with validation
Settings page with API key management
Import/export bookmarks as JSON
Shortcode to display bookmarks grid/list
AJAX bookmark management (add, edit, delete without page reload)
Category filter dropdown
Responsive design
GET /wp-json/bookmarks/v1/bookmarks - List all bookmarks
GET /wp-json/bookmarks/v1/bookmarks/:id - Get single bookmark
POST /wp-json/bookmarks/v1/bookmarks - Create bookmark (authenticated)
PUT /wp-json/bookmarks/v1/bookmarks/:id - Update bookmark (authenticated)
DELETE /wp-json/bookmarks/v1/bookmarks/:id - Delete bookmark (authenticated)
Proper permission callbacks and validation
Security & Quality:
All input sanitized, all output escaped
Nonce verification for all forms and AJAX
User capability checks
Translation ready (all strings use text domain)
No PHP errors with WP_DEBUG enabled
Follows WordPress Coding Standards
Documented with PHPDoc comments
Success Criteria:
Full CRUD operations work via admin and REST API, proper security implementation, clean code structure
Learn about 12. Quiz.
A) Actions modify data, filters execute code at specific points
B) Actions execute code at specific points, filters modify data and must return a value
C) They are the same thing with different names
D) Actions are only for themes, filters are only for plugins
Answer: B) Actions execute code at specific points, filters modify data and must return a value
Explanation: Actions allow you to execute code at specific points in WordPress execution (e.g., when a post is saved).
Filters allow you to modify data before it's used or displayed, and they must always return the modified value.
For example, use add_action() to send an email when a post is published, and use add_filter() to modify the
post content before it's displayed.
A) $wpdb->query("SELECT * FROM table WHERE id = " . $_GET['id']);
B) $wpdb->query($wpdb->prepare("SELECT * FROM table WHERE id = %d", $_GET['id']));
C) mysql_query("SELECT * FROM table WHERE id = " . intval($_GET['id']));
D) $wpdb->get_results("SELECT * FROM table WHERE id =
Answer: B) $wpdb->query($wpdb->prepare("SELECT * FROM table WHERE id = %d", $_GET['id']));
Explanation: Always use $wpdb->prepare() to safely prepare database queries. This prevents SQL injection attacks
by properly escaping values. Use %d for integers, %s for strings, and %f for floats. Option A is vulnerable to
SQL injection. Option C uses deprecated mysql_* functions. Option D doesn't properly escape the user input.
A) if ($_POST['nonce'] === 'my_nonce_value')
B) if (wp_verify_nonce($_POST['my_nonce'], 'my_action'))
C) check_nonce($_POST['my_nonce'])
D) verify_nonce('my_action')
Answer: B) if (wp_verify_nonce($_POST['my_nonce'], 'my_action'))
Explanation: wp_verify_nonce() is the correct function to verify a nonce. It takes two parameters: the nonce
value from the form/request, and the action name used when creating the nonce. It returns false if verification
fails, or 1/2 if successful (1 = generated 0-12 hours ago, 2 = generated 12-24 hours ago). For AJAX requests,
use check_ajax_referer() instead.
A) Use activation to create tables and set defaults; use deactivation to delete all plugin data
B) Use activation to create tables and set defaults; use deactivation only to clean up temporary data like cron jobs
C) They do the same thing and are interchangeable
D) Use activation for database operations; use deactivation for file operations
Answer: B) Use activation to create tables and set defaults; use deactivation only to clean up temporary data like cron jobs
Explanation: The activation hook should create database tables, set default options, add custom roles, etc.
The deactivation hook should ONLY clean up temporary data like scheduled cron events. NEVER delete user data
on deactivation because users may want to reactivate the plugin. For complete data removal, use uninstall.php
which runs when the plugin is deleted (not just deactivated).
A) <script src="my-script.js"></script> in header.php
B) wp_enqueue_script('my-script', 'my-script.js', array('jquery'), '1.0', true);
C) wp_register_script('my-script'); wp_print_scripts('my-script');
D) add_action('init', function()
'<script src="my-script.js"></script>'; });
Answer: B) wp_enqueue_script('my-script', 'my-script.js', array('jquery'), '1.0', true);
Explanation: wp_enqueue_script() is the correct way to add scripts in WordPress. The parameters are: handle (unique name),
src (URL to script), deps (array of dependencies - jQuery in this case), ver (version number), and in_footer (true = load
in footer for better performance). WordPress will automatically load jQuery before your script because of the dependency.
Never hardcode script tags or use echo to output scripts.
Learn about 13. Additional Resources.
Official Documentation
WordPress Plugin Developer Handbook
Plugin API / Hooks
REST API Handbook
WordPress Security
Hook References
Hook Reference
Action Reference
Filter Reference
Database & API
Database API (wpdb)
Settings API
AJAX in Plugins
Security Resources
Data Sanitization
Data Validation and Escaping
Plugin Development Tools
WordPress Plugin Boilerplate Generator
GenerateWP - Code Generator
Query Monitor Plugin
Debug Bar Plugin
Best Practices & Standards
WordPress PHP Coding Standards
Plugin Guidelines (WordPress.org)
PHPDoc Standards
Community & Support
WordPress Support Forums - Plugins
WordPress Stack Exchange
Make WordPress Core
Now that you understand WordPress plugin development, you're ready to move on to
Custom Post Types and Taxonomies
, where you'll learn how to create
custom content types beyond posts and pages, build custom taxonomies for organizing content,
and implement advanced meta boxes and custom fields.
Your progress is saved in this browser, on this device only.