WordPress Plugin Development: The Only Files and Hooks You Need to Know
Have you ever tried to learn WordPress plugin development by reading the official Plugin Handbook? It’s comprehensive, sure. But it’s also 50+ chapters deep, covers every edge case imaginable, and leaves you wondering, “Where do I even start?”
Here’s the truth that took me years to figure out: You don’t need to know everything. In fact, 80% of plugin development relies on about 20% of WordPress APIs.
Today, I’m going to show you exactly what that 20% is. These are the files, hooks, and functions I use in almost every plugin I build. Master these, and you’ll be able to build most WordPress plugins without constantly Googling.
Let’s get started.
The Overwhelm Problem
When I started WordPress development, I tried to learn everything. I’d read about transients, cron jobs, custom post types, taxonomies, the REST API, WP_Query, and on and on.
I felt like I needed to understand all of it before I could build anything.
That was a mistake.
The real breakthrough came when I stopped trying to learn WordPress and started trying to build specific things. Every time I needed a feature, I’d learn just enough to implement it.
Gradually, patterns emerged. I noticed I was using the same hooks over and over. The same functions. The same file structures.
That’s what this guide is about: the patterns you’ll actually use.
The Plugin File Structure (What You Actually Need)
Let’s start with the basics. Here’s the file structure I use for 90% of my plugins:
my-plugin/
├── my-plugin.php (main plugin file)
├── readme.txt (plugin description - optional)
├── includes/
│ ├── admin.php (admin-specific code)
│ ├── frontend.php (frontend-specific code)
│ └── helpers.php (utility functions)
├── assets/
│ ├── css/
│ │ └── style.css
│ └── js/
│ └── script.js
└── languages/ (for translations - optional)
That’s it. For simple plugins, you might just have the main file. For complex ones, you might add more folders (like templates/ or api/), but this structure handles most cases.
The main plugin file (my-plugin.php) contains:
- Plugin header (required)
- Security check
- Constants (paths, version number)
- Include statements for other files
- Activation/deactivation hooks (if needed)
Here’s a template I use:
<?php
/**
* Plugin Name: My Awesome Plugin
* Description: Does awesome things
* Version: 1.0.0
* Author: Taufik Hidayat
* License: GPL v2 or later
* Text Domain: my-plugin
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Define plugin constants
define( 'MY_PLUGIN_VERSION', '1.0.0' );
define( 'MY_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'MY_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
// Include files
require_once MY_PLUGIN_PATH . 'includes/admin.php';
require_once MY_PLUGIN_PATH . 'includes/frontend.php';
require_once MY_PLUGIN_PATH . 'includes/helpers.php';
// Activation hook
register_activation_hook( __FILE__, 'my_plugin_activate' );
function my_plugin_activate() {
// Code to run when plugin is activated
// Example: Create database tables, set default options
flush_rewrite_rules();
}
// Deactivation hook
register_deactivation_hook( __FILE__, 'my_plugin_deactivate' );
function my_plugin_deactivate() {
// Code to run when plugin is deactivated
flush_rewrite_rules();
}
Pro tip: I always use constants for paths and URLs. It makes code cleaner and easier to maintain.
Understanding the WordPress Hook System
This is the foundation of everything. If you understand hooks, you understand WordPress plugin development.
WordPress fires hundreds of “hooks” during each page load. These are specific points where WordPress says, “Hey, if anyone wants to do something here, now’s the time.”
Your plugin “hooks into” these points to run its own code.
There are two types:
Action Hooks
Actions let you do something at a specific point. Think of them as events.
Example: WordPress fires the wp_footer action when building the footer. You can hook into it to add your own HTML:
function my_custom_footer_text() {
echo '<p>This appears in the footer!</p>';
}
add_action( 'wp_footer', 'my_custom_footer_text' );
The anatomy of add_action():
add_action(
'hook_name', // The action hook
'your_function_name', // Function to call
10, // Priority (optional, default is 10)
1 // Number of arguments (optional, default is 1)
);
Priority determines order. Lower numbers run first. If you want your code to run after other plugins, use a higher number like 20 or 99.
Filter Hooks
Filters let you modify data before WordPress uses it.
Example: WordPress uses the the_title filter before displaying a post title. You can hook into it to modify every title:
function modify_post_title( $title ) {
return $title . ' [Read This!]';
}
add_filter( 'the_title', 'modify_post_title' );
The key difference: filters must return a value (usually the modified version of what was passed in). Actions don’t return anything.
The anatomy of add_filter():
add_filter(
'hook_name', // The filter hook
'your_function_name', // Function to call
10, // Priority (optional)
1 // Number of arguments (optional)
);
A Real Example
Let’s say you want to add a custom message before every blog post. Here’s how:
function add_custom_message( $content ) {
// Only on single posts
if ( is_single() ) {
$message = '<div class="custom-message">Thanks for reading!</div>';
$content = $message . $content;
}
return $content;
}
add_filter( 'the_content', 'add_custom_message' );
We’re using the_content filter, which fires before post content is displayed. We prepend our message and return the modified content.
The 10 Most Important Action Hooks
These action hooks cover 80% of what you’ll need. I use them constantly.
1. init
When it fires: Early in the WordPress loading process, after WordPress is fully loaded but before headers are sent.
Use it for: Registering custom post types, taxonomies, shortcodes, or anything that needs to run on every page load.
function my_plugin_init() {
// Register shortcodes
add_shortcode( 'my_shortcode', 'my_shortcode_function' );
// Register custom post type
register_post_type( 'book', array( /* args */ ) );
}
add_action( 'init', 'my_plugin_init' );
2. wp_enqueue_scripts
When it fires: When WordPress is ready to load scripts and styles on the frontend.
Use it for: Loading your plugin’s CSS and JavaScript files on the frontend.
function my_plugin_enqueue_scripts() {
wp_enqueue_style(
'my-plugin-style',
MY_PLUGIN_URL . 'assets/css/style.css',
array(),
MY_PLUGIN_VERSION
);
wp_enqueue_script(
'my-plugin-script',
MY_PLUGIN_URL . 'assets/js/script.js',
array( 'jquery' ), // Dependencies
MY_PLUGIN_VERSION,
true // Load in footer
);
}
add_action( 'wp_enqueue_scripts', 'my_plugin_enqueue_scripts' );
Important: Never just hardcode <link> or <script> tags. Always use wp_enqueue_style() and wp_enqueue_script(). This prevents conflicts and allows WordPress to optimize loading.
3. admin_enqueue_scripts
When it fires: When loading scripts/styles in the admin area.
Use it for: Loading CSS/JS that only appears in WordPress admin.
function my_plugin_admin_scripts( $hook ) {
// Only load on our plugin's settings page
if ( $hook !== 'settings_page_my-plugin' ) {
return;
}
wp_enqueue_style( 'my-plugin-admin-style', MY_PLUGIN_URL . 'assets/css/admin.css' );
}
add_action( 'admin_enqueue_scripts', 'my_plugin_admin_scripts' );
The $hook parameter tells you which admin page is loading, so you can load scripts only where needed.
4. admin_menu
When it fires: When building the WordPress admin menu.
Use it for: Adding custom admin pages, settings pages, or menu items.
function my_plugin_admin_menu() {
add_menu_page(
'My Plugin', // Page title
'My Plugin', // Menu title
'manage_options', // Capability required
'my-plugin', // Menu slug
'my_plugin_settings_page', // Function to display page
'dashicons-admin-generic', // Icon
30 // Position
);
// Or add a submenu under Settings
add_options_page(
'My Plugin Settings',
'My Plugin',
'manage_options',
'my-plugin-settings',
'my_plugin_settings_page'
);
}
add_action( 'admin_menu', 'my_plugin_admin_menu' );
function my_plugin_settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
<!-- Your settings form here -->
</div>
<?php
}
5. admin_init
When it fires: Early in the admin loading process.
Use it for: Registering settings, adding settings sections/fields.
function my_plugin_admin_init() {
register_setting(
'my_plugin_settings_group', // Option group
'my_plugin_option' // Option name
);
add_settings_section(
'my_plugin_section',
'Main Settings',
'my_plugin_section_callback',
'my-plugin'
);
add_settings_field(
'my_plugin_field',
'Setting Label',
'my_plugin_field_callback',
'my-plugin',
'my_plugin_section'
);
}
add_action( 'admin_init', 'my_plugin_admin_init' );
6. save_post
When it fires: After a post (or any post type) is saved.
Use it for: Saving custom meta data, triggering actions when content is published.
function my_plugin_save_post( $post_id ) {
// Don't run on autosaves
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Check user permissions
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Save custom field
if ( isset( $_POST['my_custom_field'] ) ) {
update_post_meta(
$post_id,
'my_custom_field',
sanitize_text_field( $_POST['my_custom_field'] )
);
}
}
add_action( 'save_post', 'my_plugin_save_post' );
7. wp_head
When it fires: Inside the <head> section of the frontend.
Use it for: Adding meta tags, analytics code, or custom CSS.
function my_plugin_add_meta_tags() {
if ( is_single() ) {
?>
<meta name="custom-meta" content="My custom meta tag">
<?php
}
}
add_action( 'wp_head', 'my_plugin_add_meta_tags' );
8. wp_footer
When it fires: Before the closing </body> tag on the frontend.
Use it for: Adding tracking scripts, popup HTML, or JavaScript that should load last.
function my_plugin_add_footer_code() {
?>
<script>
console.log('This runs on every page');
</script>
<?php
}
add_action( 'wp_footer', 'my_plugin_add_footer_code' );
9. template_redirect
When it fires: Before WordPress determines which template file to load.
Use it for: Custom redirects, blocking access to certain pages, loading custom templates.
function my_plugin_custom_redirect() {
if ( is_page( 'old-page' ) ) {
wp_redirect( home_url( '/new-page' ), 301 );
exit;
}
}
add_action( 'template_redirect', 'my_plugin_custom_redirect' );
10. rest_api_init
When it fires: When initializing the REST API.
Use it for: Registering custom REST API endpoints.
function my_plugin_register_api_routes() {
register_rest_route( 'my-plugin/v1', '/data', array(
'methods' => 'GET',
'callback' => 'my_plugin_get_data',
'permission_callback' => '__return_true'
) );
}
add_action( 'rest_api_init', 'my_plugin_register_api_routes' );
function my_plugin_get_data() {
return array( 'message' => 'Hello from my plugin API!' );
}
This creates an endpoint at yoursite.com/wp-json/my-plugin/v1/data.
The 5 Most Important Filter Hooks
Filters are for modifying data. Here are the ones I use most.
1. the_content
Filters: The post content before display.
Use it for: Adding content before/after posts, modifying post HTML, inserting ads.
function my_plugin_modify_content( $content ) {
// Add something before content
$before = '<div class="content-notice">Important notice!</div>';
// Add something after content
$after = '<div class="share-buttons">Share this post</div>';
return $before . $content . $after;
}
add_filter( 'the_content', 'my_plugin_modify_content' );
2. the_title
Filters: Post/page titles before display.
Use it for: Modifying titles, adding icons, appending text.
function my_plugin_modify_title( $title, $post_id ) {
if ( is_admin() ) {
return $title; // Don't modify in admin
}
// Add emoji to featured posts
if ( get_post_meta( $post_id, 'featured', true ) ) {
$title = '⭐ ' . $title;
}
return $title;
}
add_filter( 'the_title', 'my_plugin_modify_title', 10, 2 );
3. body_class
Filters: CSS classes applied to the <body> tag.
Use it for: Adding custom classes for styling based on conditions.
function my_plugin_body_class( $classes ) {
if ( is_user_logged_in() ) {
$classes[] = 'logged-in-user';
}
if ( is_page( 'special-page' ) ) {
$classes[] = 'special-page-style';
}
return $classes;
}
add_filter( 'body_class', 'my_plugin_body_class' );
4. wp_nav_menu_items
Filters: Menu items before they’re displayed.
Use it for: Adding custom items to menus (like login/logout links).
function my_plugin_add_menu_items( $items, $args ) {
// Only add to primary menu
if ( $args->theme_location === 'primary' ) {
if ( is_user_logged_in() ) {
$items .= '<li><a href="' . wp_logout_url() . '">Logout</a></li>';
} else {
$items .= '<li><a href="' . wp_login_url() . '">Login</a></li>';
}
}
return $items;
}
add_filter( 'wp_nav_menu_items', 'my_plugin_add_menu_items', 10, 2 );
5. plugin_action_links_{$plugin_file}
Filters: The action links shown on the Plugins page.
Use it for: Adding “Settings” link next to Activate/Deactivate.
function my_plugin_action_links( $links ) {
$settings_link = '<a href="options-general.php?page=my-plugin">Settings</a>';
array_unshift( $links, $settings_link );
return $links;
}
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'my_plugin_action_links' );
This is a small touch that makes your plugin feel professional.
Creating Your Own Custom Hooks
Here’s something powerful: you can create your own hooks in your plugin. This allows other developers (or you in other plugins) to extend your plugin.
Creating an action hook:
function my_plugin_do_something() {
// Your code here
// Allow others to hook in after your code runs
do_action( 'my_plugin_after_something' );
}
Now other code can hook into my_plugin_after_something:
function extend_my_plugin() {
// This runs after my_plugin_do_something()
}
add_action( 'my_plugin_after_something', 'extend_my_plugin' );
Creating a filter hook:
function my_plugin_get_data() {
$data = array( 'key' => 'value' );
// Allow others to modify the data
return apply_filters( 'my_plugin_data', $data );
}
Now other code can modify your data:
function modify_plugin_data( $data ) {
$data['new_key'] = 'new_value';
return $data;
}
add_filter( 'my_plugin_data', 'modify_plugin_data' );
Pro tip: Always prefix your custom hooks with your plugin name to avoid conflicts.
The WordPress Options API
For storing settings, you’ll use these three functions constantly:
get_option()
Retrieves a saved option from the database.
$value = get_option( 'my_plugin_setting', 'default_value' );
The second parameter is the default if the option doesn’t exist.
update_option()
Saves or updates an option.
update_option( 'my_plugin_setting', 'new_value' );
If the option doesn’t exist, it’s created. If it exists, it’s updated.
delete_option()
Removes an option.
delete_option( 'my_plugin_setting' );
Use this in your deactivation hook to clean up.
Example: Simple settings system
// Save setting
if ( isset( $_POST['my_plugin_save'] ) ) {
$value = sanitize_text_field( $_POST['my_plugin_field'] );
update_option( 'my_plugin_setting', $value );
}
// Retrieve setting
$current_value = get_option( 'my_plugin_setting', 'Default text' );
// Display in form
echo '<input type="text" name="my_plugin_field" value="' . esc_attr( $current_value ) . '">';
Common Patterns You’ll Use Everywhere
Pattern 1: Adding an Admin Settings Page
// Add menu item
function my_plugin_menu() {
add_options_page(
'My Plugin Settings',
'My Plugin',
'manage_options',
'my-plugin',
'my_plugin_settings_page'
);
}
add_action( 'admin_menu', 'my_plugin_menu' );
// Register settings
function my_plugin_settings_init() {
register_setting( 'my_plugin_group', 'my_plugin_option' );
}
add_action( 'admin_init', 'my_plugin_settings_init' );
// Display settings page
function my_plugin_settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
<form method="post" action="options.php">
<?php
settings_fields( 'my_plugin_group' );
do_settings_sections( 'my-plugin' );
?>
<table class="form-table">
<tr>
<th>Setting Name</th>
<td>
<input type="text" name="my_plugin_option" value="<?php echo esc_attr( get_option( 'my_plugin_option' ) ); ?>" />
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
</div>
<?php
}
Pattern 2: Enqueueing Scripts Conditionally
function my_plugin_enqueue_scripts() {
// Only on single posts
if ( is_single() ) {
wp_enqueue_script(
'my-plugin-post-script',
MY_PLUGIN_URL . 'assets/js/post.js',
array( 'jquery' ),
MY_PLUGIN_VERSION,
true
);
// Pass data to JavaScript
wp_localize_script( 'my-plugin-post-script', 'myPluginData', array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my-plugin-nonce' ),
'post_id' => get_the_ID()
) );
}
}
add_action( 'wp_enqueue_scripts', 'my_plugin_enqueue_scripts' );
In your JavaScript, you can now access myPluginData.ajax_url, etc.
Pattern 3: Creating a Shortcode
function my_plugin_shortcode( $atts ) {
// Parse attributes
$atts = shortcode_atts( array(
'title' => 'Default Title',
'count' => 5
), $atts );
// Build output
$output = '<div class="my-plugin-shortcode">';
$output .= '<h3>' . esc_html( $atts['title'] ) . '</h3>';
$output .= '<p>Count: ' . esc_html( $atts['count'] ) . '</p>';
$output .= '</div>';
return $output;
}
add_shortcode( 'my_shortcode', 'my_plugin_shortcode' );
Usage: [my_shortcode title="Hello" count="10"]
Debugging with WP_DEBUG and error_log()
When things don’t work (and they won’t, trust me), you need to debug.
Enable WordPress Debug Mode
In wp-config.php:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
@ini_set( 'display_errors', 0 );
This logs errors to wp-content/debug.log without displaying them to visitors.
Use error_log() for Custom Debugging
error_log( 'My variable value: ' . $some_variable );
// For arrays/objects
error_log( print_r( $some_array, true ) );
Check wp-content/debug.log to see your messages.
Check if Hooks Are Running
function test_if_hook_runs() {
error_log( 'Yes, this hook is firing!' );
}
add_action( 'init', 'test_if_hook_runs' );
If you don’t see the message in the log, the hook isn’t firing.
Quick Reference Cheat Sheet
Here’s a table you can bookmark and reference anytime:
| Action Hook | When It Fires | Common Use |
|---|---|---|
init | Early WordPress load | Register post types, taxonomies, shortcodes |
wp_enqueue_scripts | Frontend scripts/styles | Load CSS/JS files |
admin_enqueue_scripts | Admin scripts/styles | Load admin CSS/JS |
admin_menu | Building admin menu | Add admin pages |
admin_init | Early admin load | Register settings |
save_post | After post saved | Save custom meta data |
wp_head | In <head> section | Add meta tags, analytics |
wp_footer | Before </body> | Add footer scripts |
template_redirect | Before template loads | Custom redirects |
rest_api_init | REST API initialization | Register API endpoints |
| Filter Hook | What It Filters | Common Use |
|---|---|---|
the_content | Post content | Add/modify content |
the_title | Post title | Modify titles |
body_class | Body CSS classes | Add custom classes |
wp_nav_menu_items | Menu items | Add custom menu items |
plugin_action_links_{file} | Plugin action links | Add settings link |
| Function | Purpose | Example |
|---|---|---|
get_option() | Retrieve saved option | get_option('my_setting') |
update_option() | Save/update option | update_option('my_setting', 'value') |
delete_option() | Delete option | delete_option('my_setting') |
get_post_meta() | Get post meta | get_post_meta($id, 'key', true) |
update_post_meta() | Save post meta | update_post_meta($id, 'key', 'value') |
add_shortcode() | Register shortcode | add_shortcode('my_code', 'callback') |
wp_enqueue_script() | Load JavaScript | wp_enqueue_script('handle', 'url') |
wp_enqueue_style() | Load CSS | wp_enqueue_style('handle', 'url') |
Final Thoughts: Focus on Patterns, Not Memorization
Here’s what I wish someone had told me when I started:
You don’t need to memorize all of this. You need to understand the patterns.
- Need to run code at a specific point? Use
add_action() - Need to modify data? Use
add_filter() - Need to save a setting? Use
update_option() - Need to load a script? Use
wp_enqueue_script()
That’s it. Those are the patterns.
Every complex plugin is just these patterns repeated and combined in different ways.
The hooks I’ve listed here are the ones I use in 80% of my projects. You might need others occasionally, but start with these. Build things. When you need a hook that’s not on this list, Google it, use it, and add it to your mental toolkit.
WordPress has been around for 20+ years. There are thousands of hooks. You don’t need to know them all. You just need to know where to look when you need something specific.
Build small things. Build them often. The patterns will become second nature.
Now go build something awesome.
— Taufik Hidayat