← Back to blog

WordPress Hooks Explained: Actions, Filters, and How to Use Them

· 10 min read · WordPress

Most WordPress bugs I’ve debugged for clients come down to the same root cause: someone misunderstood how hooks work. They’re calling a function too early, returning nothing from a filter, or stacking multiple hooks on the same callback without realizing it. Hooks are the backbone of WordPress extensibility — everything from wp-admin rendering to WooCommerce checkout processing runs through them. If you’re writing plugins or themes beyond a basic level, you need a solid mental model for how this system actually behaves.

This guide covers the full picture: what hooks are, how actions and filters differ, the parameters that actually matter, removing hooks, building your own, and the mistakes that show up constantly in production code.

What WordPress Hooks Actually Are

WordPress runs top to bottom like any PHP script. Hooks are predefined points in that execution where WordPress pauses and says: “Does anyone want to do something here?” Your code registers interest in those points, and WordPress calls your function when it reaches them.

There are two kinds of hooks: actions and filters. Actions let you run code at a specific moment. Filters let you receive a value, modify it, and return it. That distinction is the entire conceptual foundation. Everything else is implementation detail.

WordPress core ships with hundreds of hooks baked in. init fires after WordPress has loaded but before any output. wp_enqueue_scripts fires when it’s time to register scripts and styles. the_content fires when post content is about to be displayed. Plugin and theme authors add their own hooks too — WooCommerce alone exposes thousands.

Actions: Running Code at the Right Moment

You register an action with add_action(). The function signature:

add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );

$hook_name is the hook you’re attaching to. $callback is your function. $priority controls execution order — lower numbers run first. $accepted_args tells WordPress how many arguments to pass to your callback.

A practical example: adding a custom script only on single post pages.

add_action( 'wp_enqueue_scripts', 'mysite_enqueue_post_scripts' );

function mysite_enqueue_post_scripts() {
    if ( ! is_single() ) {
        return;
    }

    wp_enqueue_script(
        'mysite-post-interactions',
        get_theme_file_uri( 'assets/js/post-interactions.js' ),
        array( 'jquery' ),
        '1.2.0',
        true
    );
}

The wp_enqueue_scripts hook is the correct place for this. Calling wp_enqueue_script() directly in your theme functions before that hook fires will either do nothing or throw an error depending on load order.

Here’s a more involved example — adding a custom admin column to the Posts list table. This needs three hooks working together: one to define the column, one to populate it, and optionally one to make it sortable.

// Register the column header
add_filter( 'manage_posts_columns', 'mysite_add_word_count_column' );

function mysite_add_word_count_column( $columns ) {
    $columns['word_count'] = 'Word Count';
    return $columns;
}

// Populate the column for each row
add_action( 'manage_posts_custom_column', 'mysite_render_word_count_column', 10, 2 );

function mysite_render_word_count_column( $column_name, $post_id ) {
    if ( 'word_count' !== $column_name ) {
        return;
    }

    $post    = get_post( $post_id );
    $content = wp_strip_all_tags( $post->post_content );
    $count   = str_word_count( $content );

    echo esc_html( number_format( $count ) );
}

// Make it sortable
add_filter( 'manage_edit-post_sortable_columns', 'mysite_sortable_word_count_column' );

function mysite_sortable_word_count_column( $sortable_columns ) {
    $sortable_columns['word_count'] = 'word_count';
    return $sortable_columns;
}

Notice accepted_args is set to 2 on manage_posts_custom_column. WordPress passes both the column name and the post ID to that hook. If you leave accepted_args at the default of 1, your callback only receives $column_name and you can’t look up the post data. This is one of the more common mistakes in plugin code.

Filters: Modifying Data in Transit

Filters work through add_filter() with the same signature as add_action(). The critical rule: a filter callback must always return a value. If you forget the return statement, you’ve just set whatever that variable was to null or false at that point in execution — which breaks things in ways that can be hard to trace.

add_filter( 'the_content', 'mysite_append_author_bio', 20 );

function mysite_append_author_bio( $content ) {
    if ( ! is_single() || ! in_the_loop() ) {
        return $content; // Always return, even when doing nothing
    }

    $author_id  = get_the_author_meta( 'ID' );
    $author_bio = get_the_author_meta( 'description' );

    if ( empty( $author_bio ) ) {
        return $content;
    }

    $bio_html = sprintf(
        '

About %s

%s

', esc_html( get_the_author() ), esc_html( $author_bio ) ); return $content . $bio_html; }

Priority 20 here ensures this runs after most other plugins that might be modifying the_content at the default priority of 10. If your filter runs at priority 5 and another plugin’s filter at 10 replaces the content entirely, your addition disappears.

Priority and accepted_args: The Parameters That Actually Matter

Priority determines execution order within a single hook. Multiple callbacks on the same hook run in ascending priority order. Same priority? They run in the order they were registered.

// Runs first (priority 5)
add_filter( 'the_title', 'mysite_prefix_draft_titles', 5 );

function mysite_prefix_draft_titles( $title, $post_id ) {
    $post = get_post( $post_id );
    if ( $post && 'draft' === $post->post_status ) {
        return '[DRAFT] ' . $title;
    }
    return $title;
}

// Runs second (priority 15) — receives the already-modified title
add_filter( 'the_title', 'mysite_truncate_long_titles', 15, 2 );

function mysite_truncate_long_titles( $title, $post_id ) {
    if ( strlen( $title ) > 60 ) {
        return substr( $title, 0, 57 ) . '...';
    }
    return $title;
}

Both callbacks receive $post_id as a second argument, so accepted_args is 2 on both. The the_title hook passes the title string and the post ID. If accepted_args defaults to 1, the second argument never arrives in your callback.

One thing worth knowing: WordPress uses call_user_func_array() internally and slices the arguments array based on accepted_args. Setting it higher than the number of arguments the hook actually passes doesn’t cause an error — PHP just fills the extras with null. But setting it lower means you silently lose data.

Removing Hooks

You can deregister any hook with remove_action() or remove_filter(). The signature mirrors the add functions: hook name, callback, and priority. All three must match exactly what was registered — get the priority wrong and the removal silently fails.

// Remove a core WordPress behavior: auto-paragraph in post content
remove_filter( 'the_content', 'wpautop' );

// Remove with correct priority matching
add_action( 'init', 'mysite_remove_woo_hooks' );

function mysite_remove_woo_hooks() {
    // WooCommerce registers this at priority 10
    remove_action( 'woocommerce_before_single_product', 'woocommerce_breadcrumb', 10 );
}

The timing problem is real here. You can’t remove a hook before it’s been registered. If a plugin registers its hook inside a callback attached to plugins_loaded, you need to remove it at plugins_loaded or later — not in your plugin’s top-level code that runs before that.

Removing hooks registered by anonymous functions or closures is effectively impossible without direct access to the $wp_filter global, because there’s no callable reference to match against. This is why responsible plugin authors use named functions or store references to closures in accessible variables.

Creating Custom Hooks with do_action() and apply_filters()

If you’re building a plugin, you should be exposing hooks so other developers (and future you) can extend functionality without modifying core plugin files. This is the same pattern WordPress core uses.

// In your plugin's main processing function
function mysite_process_form_submission( $form_data ) {
    // Allow modification of incoming data before processing
    $form_data = apply_filters( 'mysite_before_form_process', $form_data );

    // Validate
    $errors = mysite_validate_form( $form_data );

    // Fire an action after validation, before saving
    do_action( 'mysite_form_validated', $form_data, $errors );

    if ( ! empty( $errors ) ) {
        return new WP_Error( 'validation_failed', implode( ', ', $errors ) );
    }

    $result = mysite_save_form_data( $form_data );

    // Fire action after successful save — pass the result and original data
    do_action( 'mysite_form_saved', $result, $form_data );

    return $result;
}

Now any other plugin or theme can hook into your form processing without touching your code:

add_filter( 'mysite_before_form_process', 'mytheme_sanitize_extra_fields' );

function mytheme_sanitize_extra_fields( $form_data ) {
    if ( isset( $form_data['phone'] ) ) {
        $form_data['phone'] = preg_replace( '/[^0-9+-s()]/', '', $form_data['phone'] );
    }
    return $form_data;
}

add_action( 'mysite_form_saved', 'mytheme_send_crm_notification', 10, 2 );

function mytheme_send_crm_notification( $result, $form_data ) {
    // Send to CRM after form save, using original form data
    mycrm_push_contact( $form_data['email'], $form_data['name'] );
}

Real-World Hook Patterns

Modifying WooCommerce Checkout Fields

add_filter( 'woocommerce_checkout_fields', 'mysite_customize_checkout_fields' );

function mysite_customize_checkout_fields( $fields ) {
    // Remove billing company field
    unset( $fields['billing']['billing_company'] );

    // Make phone required
    $fields['billing']['billing_phone']['required'] = true;

    // Add a custom field for delivery instructions
    $fields['order']['delivery_instructions'] = array(
        'type'        => 'textarea',
        'label'       => 'Delivery Instructions',
        'placeholder' => 'Gate code, leave with neighbor, etc.',
        'class'       => array( 'form-row-wide' ),
        'priority'    => 5,
    );

    return $fields;
}

Customizing the Login Page Redirect

add_filter( 'login_redirect', 'mysite_custom_login_redirect', 10, 3 );

function mysite_custom_login_redirect( $redirect_to, $requested_redirect_to, $user ) {
    if ( is_wp_error( $user ) ) {
        return $redirect_to;
    }

    // Redirect subscribers to their account page, not the dashboard
    if ( in_array( 'subscriber', (array) $user->roles, true ) ) {
        return home_url( '/my-account/' );
    }

    // Redirect shop managers to the WooCommerce orders page
    if ( in_array( 'shop_manager', (array) $user->roles, true ) ) {
        return admin_url( 'edit.php?post_type=shop_order' );
    }

    return $redirect_to;
}

Modifying REST API Responses

The REST API exposes its own set of hooks. You can add custom fields to any endpoint’s response without modifying core files.

add_action( 'rest_api_init', 'mysite_register_post_meta_rest_field' );

function mysite_register_post_meta_rest_field() {
    register_rest_field(
        'post',
        'reading_time',
        array(
            'get_callback' => function( $post_data ) {
                $post    = get_post( $post_data['id'] );
                $content = wp_strip_all_tags( $post->post_content );
                $words   = str_word_count( $content );
                $minutes = ceil( $words / 200 );
                return $minutes;
            },
            'schema'       => array(
                'type'        => 'integer',
                'description' => 'Estimated reading time in minutes',
                'context'     => array( 'view' ),
            ),
        )
    );
}

Custom Cron Schedule and Task

// Register a custom interval
add_filter( 'cron_schedules', 'mysite_add_cron_intervals' );

function mysite_add_cron_intervals( $schedules ) {
    $schedules['every_six_hours'] = array(
        'interval' => 6 * HOUR_IN_SECONDS,
        'display'  => 'Every 6 Hours',
    );
    return $schedules;
}

// Schedule the event on plugin activation
register_activation_hook( __FILE__, 'mysite_schedule_cleanup' );

function mysite_schedule_cleanup() {
    if ( ! wp_next_scheduled( 'mysite_run_cleanup' ) ) {
        wp_schedule_event( time(), 'every_six_hours', 'mysite_run_cleanup' );
    }
}

// Hook the actual task to the scheduled event
add_action( 'mysite_run_cleanup', 'mysite_delete_expired_transients' );

function mysite_delete_expired_transients() {
    global $wpdb;

    $wpdb->query(
        "DELETE FROM {$wpdb->options}
         WHERE option_name LIKE '_transient_timeout_%'
         AND option_value < UNIX_TIMESTAMP()"
    );
}

Common Mistakes

Hooking Too Early

A classic: registering a custom post type inside a function that runs before init. WordPress hasn’t fully bootstrapped yet, so your post type either doesn’t register or causes a fatal. The safe hook for post type registration is init. Similarly, checking is_user_logged_in() on plugins_loaded will often return false because the authentication hasn’t happened yet at that point.

// Wrong — runs at file include time, before init
register_post_type( 'project', array( /* ... */ ) );

// Correct
add_action( 'init', function() {
    register_post_type( 'project', array( /* ... */ ) );
} );

Forgetting to Return in Filters

This breaks things silently. The the_content filter returning nothing outputs a blank post body. The woocommerce_checkout_fields filter returning nothing crashes the checkout. Always return the value — even if you haven’t changed it.

// Wrong — returns null, destroys the content
add_filter( 'the_content', function( $content ) {
    if ( is_single() ) {
        $content .= mysite_get_related_posts_html();
        // forgot return
    }
} );

// Correct
add_filter( 'the_content', function( $content ) {
    if ( is_single() ) {
        $content .= mysite_get_related_posts_html();
    }
    return $content;
} );

Wrong Priority When Removing Hooks

If you call remove_action( 'wp_head', 'wp_generator' ) but WordPress registered it at priority 1, the removal fails silently because the default priority assumed is 10. You need: remove_action( 'wp_head', 'wp_generator', 1 ). When removing third-party hooks, check how they were registered first.

Using Closures When Removal Might Be Needed

// You can never remove this later
add_action( 'template_redirect', function() {
    if ( is_page( 'secret' ) && ! is_user_logged_in() ) {
        wp_redirect( wp_login_url( get_permalink() ) );
        exit;
    }
} );

// Removable — use a named function if there's any chance it needs to be deregistered
add_action( 'template_redirect', 'mysite_protect_secret_page' );

function mysite_protect_secret_page() {
    if ( is_page( 'secret' ) && ! is_user_logged_in() ) {
        wp_redirect( wp_login_url( get_permalink() ) );
        exit;
    }
}

Assuming Hook Order Across Plugins

Two plugins both hooking woocommerce_before_checkout_form at priority 10 will run in the order WordPress loaded those plugins — which is alphabetical by folder name by default. You can’t guarantee which runs first unless you explicitly set different priorities. If your output depends on another plugin’s output at the same hook, use a higher priority number to run after it.

The Global $wp_filter Object

When you need to debug hook issues, the $wp_filter global is your source of truth. It’s an array of WP_Hook objects, indexed by hook name. Each object contains a callbacks property organized by priority.

// Dump everything registered on a specific hook
add_action( 'wp_footer', function() {
    global $wp_filter;

    if ( isset( $wp_filter['the_content'] ) ) {
        foreach ( $wp_filter['the_content']->callbacks as $priority => $callbacks ) {
            foreach ( $callbacks as $callback ) {
                $name = is_array( $callback['function'] )
                    ? get_class( $callback['function'][0] ) . '::' . $callback['function'][1]
                    : ( is_string( $callback['function'] ) ? $callback['function'] : 'closure' );

                echo "Priority {$priority}: {$name}
"; } } } } );

Run this temporarily when you’re getting unexpected output or a hook removal isn’t working. It shows exactly what’s registered and at what priority — faster than reading through multiple plugin files.

Putting It Together

The hook system is what makes WordPress composable. Your plugin doesn’t need to rewrite core behavior — it plugs into existing execution points. Your theme doesn’t need to hack WooCommerce templates — it hooks into the right action and injects content. When you’re building something that others will extend, you expose your own hooks and document what data they pass.

The mental model: actions are announcements (“this thing just happened”), filters are transformations (“here’s the data, modify it and hand it back”). Keep that distinction clear, always return from filters, match priority when removing hooks, and watch accepted_args when a hook passes more than one argument. Most hook-related bugs collapse down to one of those four things.

If you’re building something complex on top of WordPress — a multi-plugin architecture, a headless setup with custom REST endpoints, or a heavily customized WooCommerce store — feel free to reach out. This is the kind of architecture work I do regularly.