Back to blog

WordPress Plugin Development: The Only Files and Hooks You Need to Know

16 min read
WordPress Plugin Development PHP Hooks

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' );

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 );

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 HookWhen It FiresCommon Use
initEarly WordPress loadRegister post types, taxonomies, shortcodes
wp_enqueue_scriptsFrontend scripts/stylesLoad CSS/JS files
admin_enqueue_scriptsAdmin scripts/stylesLoad admin CSS/JS
admin_menuBuilding admin menuAdd admin pages
admin_initEarly admin loadRegister settings
save_postAfter post savedSave custom meta data
wp_headIn <head> sectionAdd meta tags, analytics
wp_footerBefore </body>Add footer scripts
template_redirectBefore template loadsCustom redirects
rest_api_initREST API initializationRegister API endpoints
Filter HookWhat It FiltersCommon Use
the_contentPost contentAdd/modify content
the_titlePost titleModify titles
body_classBody CSS classesAdd custom classes
wp_nav_menu_itemsMenu itemsAdd custom menu items
plugin_action_links_{file}Plugin action linksAdd settings link
FunctionPurposeExample
get_option()Retrieve saved optionget_option('my_setting')
update_option()Save/update optionupdate_option('my_setting', 'value')
delete_option()Delete optiondelete_option('my_setting')
get_post_meta()Get post metaget_post_meta($id, 'key', true)
update_post_meta()Save post metaupdate_post_meta($id, 'key', 'value')
add_shortcode()Register shortcodeadd_shortcode('my_code', 'callback')
wp_enqueue_script()Load JavaScriptwp_enqueue_script('handle', 'url')
wp_enqueue_style()Load CSSwp_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

Share:

Related Posts